mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
fix(mobile): polish channel management and iOS Settings (#8171)
## Summary Use native iOS sheets for channel editing and Add Members, with profile avatars, matching search, and destructive removal dialogs. Canvas content becomes a tappable preview with Add/Remove actions. Fix the Settings availability menu and clipped copy confirmation. Align channel action messages with chat styling and keep agent identities distinguishable across selection, attribution, and profile surfaces. ### Related issue No duplicate found in the existing mobile Settings/channel issues and PRs. ### Testing - Flutter analysis, formatting, file-size checks, focused collision regressions, and the full mobile test suite passed. - Production UIKit picker simulator check passed: distinct visible/accessibility labels, avatar updates, selected rows, and pubkey callbacks. Wired into Mobile Swift CI. - Original UI was built and tested on the physical iPhone with author confirmation; profile/community data preserved. - Review fixes await renewed author device confirmation before restoring `buzz-review-completed` and ready-for-review status. --------- Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
1
.github/workflows/_ci-clients.yml
vendored
1
.github/workflows/_ci-clients.yml
vendored
@@ -146,6 +146,7 @@ jobs:
|
||||
xcrun simctl boot "$device"
|
||||
xcrun simctl bootstatus "$device" -b
|
||||
/bin/bash scripts/test-mobile-pagination-accessibility.sh "$device"
|
||||
/bin/bash scripts/test-mobile-member-picker.sh "$device"
|
||||
- name: Build complete unsigned iOS release
|
||||
run: |
|
||||
/bin/bash scripts/test-mobile-gateway-recipes.sh
|
||||
|
||||
46
mobile/ios/NativeControlTests/AddMembersIdentityTests.swift
Normal file
46
mobile/ios/NativeControlTests/AddMembersIdentityTests.swift
Normal file
@@ -0,0 +1,46 @@
|
||||
import Flutter
|
||||
import UIKit
|
||||
|
||||
@main
|
||||
struct AddMembersIdentityTests {
|
||||
@MainActor static func main() {
|
||||
let keys = [String(repeating: "a", count: 64), String(repeating: "b", count: 64)]
|
||||
let labels = ["Scout · aaaaaaaa", "Scout · bbbbbbbb"]
|
||||
let users: [[String: Any]] = zip(keys, labels).map { key, label in
|
||||
["pubkey": key, "name": label, "detail": "Same owner", "agent": true, "initial": "S", "avatarKey": key]
|
||||
}
|
||||
var selected: [String] = []
|
||||
var avatarReplies: [String: (Data?) -> Void] = [:]
|
||||
let avatar = UIGraphicsImageRenderer(size: CGSize(width: 40, height: 40)).image { context in
|
||||
UIColor.blue.setFill()
|
||||
context.fill(CGRect(x: 0, y: 0, width: 40, height: 40))
|
||||
}.pngData()
|
||||
let picker = NativeAddMembersViewController(
|
||||
state: ["users": users, "selected": [], "query": "", "loading": false],
|
||||
loadAvatar: { key, _, reply in avatarReplies[key] = reply },
|
||||
send: { event, values in
|
||||
if event == "toggle", let key = values["pubkey"] as? String { selected.append(key) }
|
||||
}
|
||||
)
|
||||
picker.loadViewIfNeeded()
|
||||
for index in keys.indices {
|
||||
let path = IndexPath(row: index, section: 1)
|
||||
let cell = picker.tableView(picker.tableView, cellForRowAt: path)
|
||||
let content = cell.contentConfiguration as? UIListContentConfiguration
|
||||
precondition(content?.text == labels[index], "Visible identity must retain its qualifier")
|
||||
precondition(cell.isAccessibilityElement, "The row owns accessibility")
|
||||
precondition(cell.contentView.accessibilityElementsHidden, "No duplicate content stop")
|
||||
precondition(cell.accessibilityLabel == "\(labels[index]), Same owner", "VoiceOver must retain the qualifier")
|
||||
avatarReplies[keys[index]]?(avatar)
|
||||
precondition(cell.accessibilityLabel == "\(labels[index]), Same owner", "Avatar loading retains identity")
|
||||
precondition(cell.contentView.accessibilityElementsHidden, "Avatar loading keeps one accessibility owner")
|
||||
picker.tableView(picker.tableView, didSelectRowAt: path)
|
||||
}
|
||||
precondition(selected == keys, "Each distinct label must select its own pubkey")
|
||||
picker.update(["users": users, "selected": [users[1]], "query": "", "loading": false])
|
||||
let chosen = picker.tableView(picker.tableView, cellForRowAt: IndexPath(row: 0, section: 0))
|
||||
precondition(chosen.accessibilityLabel == "\(labels[1]), Same owner", "Selected identity retains its qualifier")
|
||||
precondition(chosen.accessibilityTraits.contains(.selected), "Selection is exposed to VoiceOver")
|
||||
print("Native member picker identity and accessibility passed")
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,8 @@
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
C0AF00000000000000000001 /* NativeConfirmationDialog.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AF00000000000000000002 /* NativeConfirmationDialog.swift */; };
|
||||
C0AF00000000000000000021 /* NativePresenceMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AF00000000000000000022 /* NativePresenceMenu.swift */; };
|
||||
C0AF00000000000000000011 /* NativeAddMembersSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AF00000000000000000012 /* NativeAddMembersSheet.swift */; };
|
||||
BZZ00000000000000000001 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = BZZ00000000000000000006 /* NotificationService.swift */; };
|
||||
BZZ00000000000000000002 /* NotificationService.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = BZZ00000000000000000009 /* NotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
BZZ00000000000000000020 /* BuzzPushKit in Frameworks */ = {isa = PBXBuildFile; productRef = BZZ00000000000000000022 /* BuzzPushKit */; };
|
||||
@@ -91,6 +93,8 @@
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
C0AF00000000000000000002 /* NativeConfirmationDialog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeConfirmationDialog.swift; sourceTree = "<group>"; };
|
||||
C0AF00000000000000000022 /* NativePresenceMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativePresenceMenu.swift; sourceTree = "<group>"; };
|
||||
C0AF00000000000000000012 /* NativeAddMembersSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeAddMembersSheet.swift; sourceTree = "<group>"; };
|
||||
BZZ00000000000000000006 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = "<group>"; };
|
||||
BZZ00000000000000000007 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
BZZ00000000000000000008 /* NotificationService.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = NotificationService.entitlements; sourceTree = "<group>"; };
|
||||
@@ -282,6 +286,8 @@
|
||||
4A71C0162F40B00100A17E01 /* HuddleMediaPlugin.swift */,
|
||||
4A71C0182F40C00100A17E01 /* HuddleAudioEngine.swift */,
|
||||
C0AF00000000000000000002 /* NativeConfirmationDialog.swift */,
|
||||
C0AF00000000000000000022 /* NativePresenceMenu.swift */,
|
||||
C0AF00000000000000000012 /* NativeAddMembersSheet.swift */,
|
||||
4A71C01A2F40D00100A17E01 /* NativeProfileTextEditor.swift */,
|
||||
4A71C01C2F40E00100A17E01 /* NativeSkinToneControl.swift */,
|
||||
4A71C01E2F40F00100A17E01 /* ThemePaginationGlassControl.swift */,
|
||||
@@ -616,6 +622,8 @@
|
||||
4A71C0152F40B00100A17E01 /* HuddleMediaPlugin.swift in Sources */,
|
||||
4A71C0172F40C00100A17E01 /* HuddleAudioEngine.swift in Sources */,
|
||||
C0AF00000000000000000001 /* NativeConfirmationDialog.swift in Sources */,
|
||||
C0AF00000000000000000021 /* NativePresenceMenu.swift in Sources */,
|
||||
C0AF00000000000000000011 /* NativeAddMembersSheet.swift in Sources */,
|
||||
4A71C0192F40D00100A17E01 /* NativeProfileTextEditor.swift in Sources */,
|
||||
4A71C01B2F40E00100A17E01 /* NativeSkinToneControl.swift in Sources */,
|
||||
4A71C01D2F40F00100A17E01 /* ThemePaginationGlassControl.swift in Sources */,
|
||||
|
||||
@@ -41,6 +41,7 @@ import os.log
|
||||
private var concentricSheetSurfaceChannel: FlutterMethodChannel?
|
||||
private var nativeAttachmentPopoverCoordinator: NativeAttachmentPopoverCoordinator?
|
||||
private var nativeEmojiPickerCoordinator: NativeEmojiPickerCoordinator?
|
||||
private var nativeAddMembersSheetCoordinator: NativeAddMembersSheetCoordinator?
|
||||
private var nativeConfirmationDialogCoordinator: NativeConfirmationDialogCoordinator?
|
||||
private var nativeProfileTextEditorCoordinator: NativeProfileTextEditorCoordinator?
|
||||
private var nativeMessageActionSurfaceSupportChannel: FlutterMethodChannel?
|
||||
@@ -244,6 +245,17 @@ import os.log
|
||||
parentViewController: nativeEmojiPickerRegistrar?.viewController
|
||||
)
|
||||
|
||||
engineBridge.pluginRegistry.registrar(forPlugin: "BuzzNativePresenceMenu")?.register(
|
||||
NativePresenceMenuFactory(messenger: messenger), withId: "buzz/presence_menu"
|
||||
)
|
||||
|
||||
nativeAddMembersSheetCoordinator = NativeAddMembersSheetCoordinator(
|
||||
messenger: messenger,
|
||||
parentViewController: engineBridge.pluginRegistry.registrar(
|
||||
forPlugin: "BuzzNativeAddMembersSheet"
|
||||
)?.viewController
|
||||
)
|
||||
|
||||
nativeConfirmationDialogCoordinator = NativeConfirmationDialogCoordinator(
|
||||
messenger: messenger,
|
||||
parentViewController: engineBridge.pluginRegistry.registrar(
|
||||
|
||||
302
mobile/ios/Runner/NativeAddMembersSheet.swift
Normal file
302
mobile/ios/Runner/NativeAddMembersSheet.swift
Normal file
@@ -0,0 +1,302 @@
|
||||
import Flutter
|
||||
import UIKit
|
||||
|
||||
/// UIKit owns presentation; Flutter retains search, selection and relay writes.
|
||||
final class NativeAddMembersSheetCoordinator: NSObject, UIAdaptivePresentationControllerDelegate {
|
||||
private let channel: FlutterMethodChannel
|
||||
private weak var parent: UIViewController?
|
||||
private var navigation: UINavigationController?
|
||||
private var picker: NativeAddMembersViewController?
|
||||
private var session: String?
|
||||
|
||||
init(messenger: FlutterBinaryMessenger, parentViewController: UIViewController?) {
|
||||
channel = FlutterMethodChannel(name: "buzz/add_members_sheet", binaryMessenger: messenger)
|
||||
parent = parentViewController
|
||||
super.init()
|
||||
channel.setMethodCallHandler { [weak self] call, result in
|
||||
self?.handle(call, result: result)
|
||||
}
|
||||
}
|
||||
|
||||
private func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
guard let data = call.arguments as? [String: Any], let request = data["session"] as? String else {
|
||||
result(FlutterError(code: "invalid_arguments", message: "Expected a picker session.", details: nil))
|
||||
return
|
||||
}
|
||||
switch call.method {
|
||||
case "present":
|
||||
guard navigation == nil else {
|
||||
result(FlutterError(code: "busy", message: "A member picker is already open.", details: nil))
|
||||
return
|
||||
}
|
||||
var presenter = parent ?? UIApplication.shared.connectedScenes
|
||||
.compactMap { $0 as? UIWindowScene }
|
||||
.filter { $0.activationState == .foregroundActive }
|
||||
.flatMap(\.windows).first(where: \.isKeyWindow)?.rootViewController
|
||||
while let next = presenter?.presentedViewController { presenter = next }
|
||||
guard let presenter, presenter.view.window != nil, !presenter.isBeingDismissed else {
|
||||
result(FlutterError(code: "unavailable", message: "Cannot open the member picker.", details: nil))
|
||||
return
|
||||
}
|
||||
session = request
|
||||
let picker = NativeAddMembersViewController(state: data, loadAvatar: { [weak self] pubkey, key, completion in
|
||||
guard let self, self.session == request else { completion(nil); return }
|
||||
self.channel.invokeMethod("avatar", arguments: ["session": request, "pubkey": pubkey, "avatarKey": key]) { value in
|
||||
completion((value as? FlutterStandardTypedData)?.data)
|
||||
}
|
||||
}) { [weak self] event, values in
|
||||
guard let self, self.session == request else { return }
|
||||
if event == "closed" {
|
||||
self.close(notify: true)
|
||||
} else {
|
||||
self.channel.invokeMethod(event, arguments: ["session": request].merging(values) { _, new in new })
|
||||
}
|
||||
}
|
||||
let navigation = UINavigationController(rootViewController: picker)
|
||||
navigation.overrideUserInterfaceStyle = data["dark"] as? Bool == true ? .dark : .light
|
||||
if UIDevice.current.userInterfaceIdiom == .pad { navigation.modalPresentationStyle = .formSheet }
|
||||
navigation.sheetPresentationController?.detents = [.large()]
|
||||
self.navigation = navigation
|
||||
self.picker = picker
|
||||
presenter.present(navigation, animated: !UIAccessibility.isReduceMotionEnabled) {
|
||||
navigation.presentationController?.delegate = self
|
||||
result(nil)
|
||||
}
|
||||
case "update":
|
||||
if request == session { picker?.update(data) }
|
||||
result(nil)
|
||||
case "dismiss":
|
||||
if request == session { close(notify: false) }
|
||||
result(nil)
|
||||
default:
|
||||
result(FlutterMethodNotImplemented)
|
||||
}
|
||||
}
|
||||
|
||||
private func close(notify: Bool) {
|
||||
guard let request = session, let navigation else { return }
|
||||
session = nil
|
||||
self.navigation = nil
|
||||
picker = nil
|
||||
navigation.dismiss(animated: !UIAccessibility.isReduceMotionEnabled) { [weak self] in
|
||||
if notify { self?.channel.invokeMethod("closed", arguments: ["session": request]) }
|
||||
}
|
||||
}
|
||||
|
||||
func presentationControllerDidDismiss(_ presentationController: UIPresentationController) {
|
||||
guard let request = session else { return }
|
||||
session = nil
|
||||
navigation = nil
|
||||
picker = nil
|
||||
channel.invokeMethod("closed", arguments: ["session": request])
|
||||
}
|
||||
}
|
||||
|
||||
final class NativeAddMembersViewController: UITableViewController, UISearchResultsUpdating {
|
||||
private var state: [String: Any]
|
||||
private let send: (String, [String: Any]) -> Void
|
||||
private let loadAvatar: (String, String, @escaping (Data?) -> Void) -> Void
|
||||
private let search = UISearchController(searchResultsController: nil)
|
||||
private let pageColor: UIColor
|
||||
private let rowColor: UIColor
|
||||
private var selected: [[String: Any]] { state["selected"] as? [[String: Any]] ?? [] }
|
||||
private var users: [[String: Any]] {
|
||||
let selectedKeys = Set(selected.compactMap { $0["pubkey"] as? String })
|
||||
return (state["users"] as? [[String: Any]] ?? []).filter {
|
||||
!selectedKeys.contains($0["pubkey"] as? String ?? "")
|
||||
}
|
||||
}
|
||||
private var busy: Bool { state["submitting"] as? Bool == true }
|
||||
private var loading: Bool { state["loading"] as? Bool == true }
|
||||
private var loadError: Bool { state["loadError"] as? Bool == true }
|
||||
|
||||
init(state: [String: Any], loadAvatar: @escaping (String, String, @escaping (Data?) -> Void) -> Void, send: @escaping (String, [String: Any]) -> Void) {
|
||||
self.state = state
|
||||
self.loadAvatar = loadAvatar
|
||||
self.send = send
|
||||
pageColor = Self.color(state["pageColor"]) ?? .systemGroupedBackground
|
||||
rowColor = Self.color(state["rowColor"]) ?? .secondarySystemGroupedBackground
|
||||
super.init(style: .insetGrouped)
|
||||
title = "Add members"
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
tableView.backgroundColor = pageColor
|
||||
tableView.keyboardDismissMode = .interactive
|
||||
tableView.rowHeight = UITableView.automaticDimension
|
||||
tableView.estimatedRowHeight = 64
|
||||
search.obscuresBackgroundDuringPresentation = false
|
||||
search.hidesNavigationBarDuringPresentation = false
|
||||
search.automaticallyShowsCancelButton = false
|
||||
search.searchResultsUpdater = self
|
||||
search.searchBar.placeholder = "Search for people or agents"
|
||||
search.searchBar.autocorrectionType = .no
|
||||
search.searchBar.autocapitalizationType = .none
|
||||
search.searchBar.showsCancelButton = false
|
||||
navigationItem.searchController = search
|
||||
navigationItem.hidesSearchBarWhenScrolling = false
|
||||
definesPresentationContext = true
|
||||
navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(cancel))
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(title: "Add", style: .done, target: self, action: #selector(add))
|
||||
let appearance = UINavigationBarAppearance()
|
||||
appearance.configureWithOpaqueBackground()
|
||||
appearance.backgroundColor = pageColor
|
||||
appearance.shadowColor = .clear
|
||||
navigationItem.standardAppearance = appearance
|
||||
navigationItem.scrollEdgeAppearance = appearance
|
||||
applyState()
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
search.isActive = true
|
||||
search.searchBar.becomeFirstResponder()
|
||||
}
|
||||
|
||||
func update(_ data: [String: Any]) {
|
||||
// A queued render from before the latest keystroke must not replace results.
|
||||
guard (data["query"] as? String ?? "") == (search.searchBar.text ?? "") else { return }
|
||||
state = data
|
||||
if isViewLoaded { applyState() }
|
||||
}
|
||||
|
||||
private func applyState() {
|
||||
navigationController?.isModalInPresentation = busy
|
||||
navigationItem.leftBarButtonItem?.isEnabled = !busy
|
||||
navigationItem.rightBarButtonItem?.isEnabled = !busy && !selected.isEmpty
|
||||
navigationItem.rightBarButtonItem?.title = busy ? "Adding…" : selected.isEmpty ? "Add" : "Add (\(selected.count))"
|
||||
search.searchBar.isUserInteractionEnabled = !busy
|
||||
tableView.reloadData()
|
||||
let error = state["error"] as? String ?? ""
|
||||
if error.isEmpty {
|
||||
tableView.tableFooterView = nil
|
||||
} else {
|
||||
let label = UILabel()
|
||||
label.text = error
|
||||
label.textColor = .systemRed
|
||||
label.font = .preferredFont(forTextStyle: .footnote)
|
||||
label.adjustsFontForContentSizeCategory = true
|
||||
label.numberOfLines = 0
|
||||
label.textAlignment = .center
|
||||
label.frame = CGRect(x: 20, y: 0, width: max(1, tableView.bounds.width - 40), height: 0)
|
||||
label.sizeToFit()
|
||||
tableView.tableFooterView = label
|
||||
UIAccessibility.post(notification: .announcement, argument: error)
|
||||
}
|
||||
}
|
||||
|
||||
func updateSearchResults(for searchController: UISearchController) {
|
||||
guard !busy else { return }
|
||||
let query = searchController.searchBar.text ?? ""
|
||||
guard query != (state["query"] as? String ?? "") else { return }
|
||||
state["query"] = query
|
||||
state["loading"] = true
|
||||
tableView.reloadData()
|
||||
send("query", ["value": searchController.searchBar.text ?? ""])
|
||||
}
|
||||
|
||||
@objc private func cancel() { if !busy { send("closed", [:]) } }
|
||||
@objc private func add() {
|
||||
guard !busy && !selected.isEmpty else { return }
|
||||
state["submitting"] = true
|
||||
applyState()
|
||||
send("submit", [:])
|
||||
}
|
||||
|
||||
override func numberOfSections(in tableView: UITableView) -> Int { 2 }
|
||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
section == 0 ? selected.count : (loading || loadError || users.isEmpty ? 1 : users.count)
|
||||
}
|
||||
override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
|
||||
section == 0 ? (selected.isEmpty ? nil : "Selected") : "People and agents"
|
||||
}
|
||||
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil)
|
||||
cell.backgroundColor = rowColor
|
||||
var content = cell.defaultContentConfiguration()
|
||||
var avatarRequest: (String, String)?
|
||||
var memberLabel: String?
|
||||
if indexPath.section == 1 && (loading || loadError || users.isEmpty) {
|
||||
content.text = loading ? "Searching…" : loadError ? "Couldn't load people or agents. Tap to retry." : "No matching people or agents."
|
||||
content.textProperties.color = .secondaryLabel
|
||||
content.textProperties.numberOfLines = 0
|
||||
cell.selectionStyle = loadError && !loading ? .default : .none
|
||||
if loadError && !loading { cell.accessibilityTraits.insert(.button) }
|
||||
} else {
|
||||
let user = indexPath.section == 0 ? selected[indexPath.row] : users[indexPath.row]
|
||||
content.text = user["name"] as? String
|
||||
content.secondaryText = user["detail"] as? String
|
||||
memberLabel = [content.text, content.secondaryText]
|
||||
.compactMap { $0 }.filter { !$0.isEmpty }.joined(separator: ", ")
|
||||
content.textProperties.numberOfLines = 0
|
||||
content.secondaryTextProperties.numberOfLines = 1
|
||||
content.image = fallbackAvatar(user)
|
||||
content.imageProperties.maximumSize = CGSize(width: 40, height: 40)
|
||||
content.imageProperties.reservedLayoutSize = CGSize(width: 40, height: 40)
|
||||
if let pubkey = user["pubkey"] as? String, let key = user["avatarKey"] as? String {
|
||||
avatarRequest = (pubkey, key)
|
||||
}
|
||||
cell.accessoryType = indexPath.section == 0 ? .checkmark : .none
|
||||
cell.accessibilityTraits.insert(.button)
|
||||
if indexPath.section == 0 { cell.accessibilityTraits.insert(.selected) }
|
||||
}
|
||||
cell.contentConfiguration = content
|
||||
if let memberLabel {
|
||||
cell.isAccessibilityElement = true
|
||||
cell.accessibilityLabel = memberLabel
|
||||
cell.contentView.accessibilityElementsHidden = true
|
||||
}
|
||||
if let (pubkey, key) = avatarRequest {
|
||||
cell.accessibilityIdentifier = key
|
||||
loadAvatar(pubkey, key) { [weak cell] data in
|
||||
guard let cell, cell.accessibilityIdentifier == key,
|
||||
let data, let image = UIImage(data: data),
|
||||
var configuration = cell.contentConfiguration as? UIListContentConfiguration else { return }
|
||||
configuration.image = image.withRenderingMode(.alwaysOriginal)
|
||||
let label = cell.accessibilityLabel
|
||||
cell.contentConfiguration = configuration
|
||||
cell.accessibilityLabel = label
|
||||
cell.contentView.accessibilityElementsHidden = true
|
||||
}
|
||||
}
|
||||
cell.isUserInteractionEnabled = !busy
|
||||
return cell
|
||||
}
|
||||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
guard !busy else { return }
|
||||
if indexPath.section == 1 && (loading || loadError || users.isEmpty) {
|
||||
if loadError && !loading { send("retry", [:]) }
|
||||
return
|
||||
}
|
||||
let user = indexPath.section == 0 ? selected[indexPath.row] : users[indexPath.row]
|
||||
if let pubkey = user["pubkey"] as? String { send("toggle", ["pubkey": pubkey]) }
|
||||
}
|
||||
|
||||
private func fallbackAvatar(_ user: [String: Any]) -> UIImage {
|
||||
let size = CGSize(width: 40, height: 40)
|
||||
return UIGraphicsImageRenderer(size: size).image { _ in
|
||||
(Self.color(user["avatarBackground"]) ?? .tertiarySystemFill).setFill()
|
||||
UIBezierPath(roundedRect: CGRect(origin: .zero, size: size),
|
||||
cornerRadius: user["agent"] as? Bool == true ? 12 : 20).fill()
|
||||
let text = (user["initial"] as? String ?? "?") as NSString
|
||||
let attributes: [NSAttributedString.Key: Any] = [
|
||||
.font: UIFont.systemFont(ofSize: 18, weight: .medium),
|
||||
.foregroundColor: Self.color(user["avatarForeground"]) ?? UIColor.label,
|
||||
]
|
||||
let measured = text.size(withAttributes: attributes)
|
||||
text.draw(at: CGPoint(x: (40 - measured.width) / 2, y: (40 - measured.height) / 2), withAttributes: attributes)
|
||||
}.withRenderingMode(.alwaysOriginal)
|
||||
}
|
||||
|
||||
private static func color(_ value: Any?) -> UIColor? {
|
||||
guard let number = value as? NSNumber else { return nil }
|
||||
let argb = number.uint32Value
|
||||
return UIColor(red: CGFloat((argb >> 16) & 255) / 255, green: CGFloat((argb >> 8) & 255) / 255,
|
||||
blue: CGFloat(argb & 255) / 255, alpha: CGFloat((argb >> 24) & 255) / 255)
|
||||
}
|
||||
}
|
||||
79
mobile/ios/Runner/NativePresenceMenu.swift
Normal file
79
mobile/ios/Runner/NativePresenceMenu.swift
Normal file
@@ -0,0 +1,79 @@
|
||||
import Flutter
|
||||
import UIKit
|
||||
|
||||
final class NativePresenceMenuFactory: 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 {
|
||||
NativePresenceMenu(frame: frame, id: viewId, args: args, messenger: messenger)
|
||||
}
|
||||
}
|
||||
|
||||
private final class NativePresenceMenu: NSObject, FlutterPlatformView {
|
||||
private let container: UIView
|
||||
private let button = UIButton(type: .system)
|
||||
private let channel: FlutterMethodChannel
|
||||
|
||||
init(frame: CGRect, id: Int64, args: Any?, messenger: FlutterBinaryMessenger) {
|
||||
container = UIView(frame: frame)
|
||||
channel = FlutterMethodChannel(name: "buzz/presence_menu/\(id)", binaryMessenger: messenger)
|
||||
super.init()
|
||||
container.backgroundColor = .clear
|
||||
container.isOpaque = false
|
||||
button.translatesAutoresizingMaskIntoConstraints = false
|
||||
button.showsMenuAsPrimaryAction = true
|
||||
container.addSubview(button)
|
||||
NSLayoutConstraint.activate([
|
||||
button.leadingAnchor.constraint(equalTo: container.leadingAnchor),
|
||||
button.trailingAnchor.constraint(equalTo: container.trailingAnchor),
|
||||
button.topAnchor.constraint(equalTo: container.topAnchor),
|
||||
button.bottomAnchor.constraint(equalTo: container.bottomAnchor),
|
||||
])
|
||||
update(args as? [String: Any] ?? [:])
|
||||
channel.setMethodCallHandler { [weak self] call, result in
|
||||
guard call.method == "update", let data = call.arguments as? [String: Any] else {
|
||||
result(FlutterMethodNotImplemented); return
|
||||
}
|
||||
self?.update(data)
|
||||
result(nil)
|
||||
}
|
||||
}
|
||||
|
||||
func view() -> UIView { container }
|
||||
|
||||
private func update(_ data: [String: Any]) {
|
||||
let presence = data["presence"] as? String ?? "offline"
|
||||
let label = data["label"] as? String ?? "Offline"
|
||||
container.overrideUserInterfaceStyle = data["dark"] as? Bool == true ? .dark : .light
|
||||
var configuration = UIButton.Configuration.plain()
|
||||
configuration.title = label
|
||||
configuration.baseForegroundColor = Self.color(data["foreground"])
|
||||
configuration.background.backgroundColor = Self.color(data["background"])
|
||||
configuration.background.cornerRadius = 100
|
||||
configuration.background.backgroundInsets = NSDirectionalEdgeInsets(top: 7, leading: 0, bottom: 7, trailing: 0)
|
||||
configuration.contentInsets = NSDirectionalEdgeInsets(top: 7, leading: 12, bottom: 7, trailing: 12)
|
||||
let fontSize = (data["fontSize"] as? NSNumber)?.doubleValue ?? 15
|
||||
configuration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { incoming in
|
||||
var attributes = incoming
|
||||
attributes.font = UIFont.systemFont(ofSize: fontSize, weight: .medium)
|
||||
return attributes
|
||||
}
|
||||
button.configuration = configuration
|
||||
button.accessibilityLabel = "Availability"
|
||||
button.accessibilityValue = label
|
||||
let options: [(String, String, UIColor)] = [("online", "Online", .systemGreen), ("away", "Away", .systemOrange), ("offline", "Offline", .systemGray)]
|
||||
button.menu = UIMenu(children: options.map { value, title, color in
|
||||
UIAction(title: title,
|
||||
image: UIImage(systemName: "circle.fill")?.withTintColor(color, renderingMode: .alwaysOriginal),
|
||||
state: presence == value ? .on : .off
|
||||
) { [weak self] _ in self?.channel.invokeMethod("selected", arguments: value) }
|
||||
})
|
||||
}
|
||||
|
||||
private static func color(_ value: Any?) -> UIColor {
|
||||
let argb = (value as? NSNumber)?.uint32Value ?? 0
|
||||
return UIColor(red: CGFloat((argb >> 16) & 255) / 255, green: CGFloat((argb >> 8) & 255) / 255,
|
||||
blue: CGFloat(argb & 255) / 255, alpha: CGFloat((argb >> 24) & 255) / 255)
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,7 @@ final class NativeProfileTextEditorCoordinator: NSObject,
|
||||
_ call: FlutterMethodCall,
|
||||
result: @escaping FlutterResult
|
||||
) {
|
||||
guard call.method == "present" else {
|
||||
guard call.method == "present" || call.method == "presentChannel" else {
|
||||
result(FlutterMethodNotImplemented)
|
||||
return
|
||||
}
|
||||
@@ -66,6 +66,13 @@ final class NativeProfileTextEditorCoordinator: NSObject,
|
||||
containerBackgroundColor: UIColor(argb: containerBackgroundArgb.uint32Value),
|
||||
containerCornerRadius: CGFloat(containerCornerRadius.doubleValue),
|
||||
allowUnchangedSubmission: allowUnchangedSubmission,
|
||||
channelDescription: call.method == "presentChannel" ? arguments["description"] as? String : nil,
|
||||
originalName: arguments["originalName"] as? String,
|
||||
originalDescription: arguments["originalDescription"] as? String,
|
||||
canEditDetails: arguments["canEditDetails"] as? Bool ?? true,
|
||||
canEditCanvas: arguments["canEditCanvas"] as? Bool ?? false,
|
||||
canvasContent: arguments["canvasContent"] as? String ?? "",
|
||||
canvasLoaded: arguments["canvasLoaded"] as? Bool ?? false,
|
||||
result: result
|
||||
)
|
||||
}
|
||||
@@ -82,6 +89,13 @@ final class NativeProfileTextEditorCoordinator: NSObject,
|
||||
containerBackgroundColor: UIColor,
|
||||
containerCornerRadius: CGFloat,
|
||||
allowUnchangedSubmission: Bool,
|
||||
channelDescription: String?,
|
||||
originalName: String?,
|
||||
originalDescription: String?,
|
||||
canEditDetails: Bool,
|
||||
canEditCanvas: Bool,
|
||||
canvasContent: String,
|
||||
canvasLoaded: Bool,
|
||||
result: @escaping FlutterResult
|
||||
) {
|
||||
guard presentedController == nil else {
|
||||
@@ -118,6 +132,13 @@ final class NativeProfileTextEditorCoordinator: NSObject,
|
||||
containerBackgroundColor: containerBackgroundColor,
|
||||
containerCornerRadius: containerCornerRadius,
|
||||
allowUnchangedSubmission: allowUnchangedSubmission,
|
||||
channelDescription: channelDescription,
|
||||
originalName: originalName,
|
||||
originalDescription: originalDescription,
|
||||
canEditDetails: canEditDetails,
|
||||
canEditCanvas: canEditCanvas,
|
||||
canvasContent: canvasContent,
|
||||
canvasLoaded: canvasLoaded,
|
||||
onCancel: { [weak self] in self?.finish(value: nil) },
|
||||
onSet: { [weak self] value in self?.finish(value: value) }
|
||||
)
|
||||
@@ -129,6 +150,10 @@ final class NativeProfileTextEditorCoordinator: NSObject,
|
||||
navigationController.modalPresentationStyle = .formSheet
|
||||
}
|
||||
|
||||
if channelDescription != nil {
|
||||
navigationController.sheetPresentationController?.detents = [.large()]
|
||||
}
|
||||
|
||||
pendingResult = result
|
||||
presentedController = navigationController
|
||||
presenter.present(navigationController, animated: true) { [weak self] in
|
||||
@@ -137,7 +162,7 @@ final class NativeProfileTextEditorCoordinator: NSObject,
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func finish(value: String?) {
|
||||
private func finish(value: Any?) {
|
||||
guard let controller = presentedController else {
|
||||
resolve(value: value)
|
||||
return
|
||||
@@ -154,7 +179,7 @@ final class NativeProfileTextEditorCoordinator: NSObject,
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func resolve(value: String?) {
|
||||
private func resolve(value: Any?) {
|
||||
let result = pendingResult
|
||||
pendingResult = nil
|
||||
presentedController = nil
|
||||
@@ -201,7 +226,18 @@ private final class NativeProfileTextEditorViewController:
|
||||
private let containerCornerRadius: CGFloat
|
||||
private let allowUnchangedSubmission: Bool
|
||||
private let onCancel: () -> Void
|
||||
private let onSet: (String) -> Void
|
||||
private let onSet: (Any) -> Void
|
||||
private let channelDescription: String?
|
||||
private let originalName: String?
|
||||
private let originalDescription: String?
|
||||
private let canEditDetails: Bool
|
||||
private let canEditCanvas: Bool
|
||||
private let canvasContent: String
|
||||
private let canvasLoaded: Bool
|
||||
private var hasCanvas: Bool {
|
||||
canvasLoaded && !canvasContent.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
private var isChannelEditor: Bool { channelDescription != nil }
|
||||
|
||||
private lazy var textField: UITextField = {
|
||||
let field = UITextField()
|
||||
@@ -211,7 +247,9 @@ private final class NativeProfileTextEditorViewController:
|
||||
field.font = .preferredFont(forTextStyle: .body)
|
||||
field.adjustsFontForContentSizeCategory = true
|
||||
field.clearButtonMode = .whileEditing
|
||||
field.returnKeyType = .done
|
||||
field.returnKeyType = isChannelEditor ? .next : .done
|
||||
field.isEnabled = canEditDetails
|
||||
field.accessibilityLabel = placeholder
|
||||
field.autocapitalizationType = .sentences
|
||||
field.delegate = self
|
||||
field.addTarget(self, action: #selector(textDidChange), for: .editingChanged)
|
||||
@@ -221,7 +259,9 @@ private final class NativeProfileTextEditorViewController:
|
||||
private lazy var textView: UITextView = {
|
||||
let view = UITextView()
|
||||
view.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.text = initialValue
|
||||
view.text = channelDescription ?? initialValue
|
||||
view.isEditable = canEditDetails
|
||||
view.accessibilityLabel = isChannelEditor ? "Description" : placeholder
|
||||
view.font = .preferredFont(forTextStyle: .body)
|
||||
view.adjustsFontForContentSizeCategory = true
|
||||
view.backgroundColor = .clear
|
||||
@@ -235,7 +275,7 @@ private final class NativeProfileTextEditorViewController:
|
||||
private lazy var placeholderLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.translatesAutoresizingMaskIntoConstraints = false
|
||||
label.text = placeholder
|
||||
label.text = isChannelEditor ? "Description" : placeholder
|
||||
label.font = .preferredFont(forTextStyle: .body)
|
||||
label.adjustsFontForContentSizeCategory = true
|
||||
label.textColor = .placeholderText
|
||||
@@ -252,8 +292,15 @@ private final class NativeProfileTextEditorViewController:
|
||||
containerBackgroundColor: UIColor,
|
||||
containerCornerRadius: CGFloat,
|
||||
allowUnchangedSubmission: Bool,
|
||||
channelDescription: String?,
|
||||
originalName: String?,
|
||||
originalDescription: String?,
|
||||
canEditDetails: Bool,
|
||||
canEditCanvas: Bool,
|
||||
canvasContent: String,
|
||||
canvasLoaded: Bool,
|
||||
onCancel: @escaping () -> Void,
|
||||
onSet: @escaping (String) -> Void
|
||||
onSet: @escaping (Any) -> Void
|
||||
) {
|
||||
self.initialValue = initialValue
|
||||
self.placeholder = placeholder
|
||||
@@ -262,6 +309,13 @@ private final class NativeProfileTextEditorViewController:
|
||||
self.containerBackgroundColor = containerBackgroundColor
|
||||
self.containerCornerRadius = containerCornerRadius
|
||||
self.allowUnchangedSubmission = allowUnchangedSubmission
|
||||
self.channelDescription = channelDescription
|
||||
self.originalName = originalName
|
||||
self.originalDescription = originalDescription
|
||||
self.canEditDetails = canEditDetails
|
||||
self.canEditCanvas = canEditCanvas
|
||||
self.canvasContent = canvasContent
|
||||
self.canvasLoaded = canvasLoaded
|
||||
self.onCancel = onCancel
|
||||
self.onSet = onSet
|
||||
super.init(style: .insetGrouped)
|
||||
@@ -297,7 +351,7 @@ private final class NativeProfileTextEditorViewController:
|
||||
action: #selector(cancelTapped)
|
||||
)
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||
title: "Set",
|
||||
title: isChannelEditor ? "Save" : "Set",
|
||||
style: .done,
|
||||
target: self,
|
||||
action: #selector(setTapped)
|
||||
@@ -307,6 +361,7 @@ private final class NativeProfileTextEditorViewController:
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
guard canEditDetails else { return }
|
||||
if multiline {
|
||||
textView.becomeFirstResponder()
|
||||
} else {
|
||||
@@ -319,7 +374,14 @@ private final class NativeProfileTextEditorViewController:
|
||||
}
|
||||
|
||||
private var hasUnsavedChanges: Bool {
|
||||
allowUnchangedSubmission
|
||||
if isChannelEditor {
|
||||
return allowUnchangedSubmission
|
||||
|| currentValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
!= (originalName ?? initialValue).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|| textView.text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
!= (originalDescription ?? channelDescription ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
return allowUnchangedSubmission
|
||||
|| currentValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
!= initialValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
@@ -338,7 +400,8 @@ private final class NativeProfileTextEditorViewController:
|
||||
}
|
||||
|
||||
private func updateNavigation() {
|
||||
navigationItem.rightBarButtonItem?.isEnabled = hasUnsavedChanges
|
||||
navigationItem.rightBarButtonItem?.isEnabled = canEditDetails && hasUnsavedChanges
|
||||
&& (!isChannelEditor || !canonicalChannelName.isEmpty)
|
||||
navigationController?.isModalInPresentation = hasUnsavedChanges
|
||||
placeholderLabel.isHidden = !textView.text.isEmpty
|
||||
}
|
||||
@@ -365,17 +428,59 @@ private final class NativeProfileTextEditorViewController:
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
private var canonicalChannelName: String {
|
||||
currentValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.replacingOccurrences(of: "^#+", with: "", options: .regularExpression)
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
@objc private func setTapped() {
|
||||
guard hasUnsavedChanges else { return }
|
||||
onSet(currentValue)
|
||||
guard canEditDetails && hasUnsavedChanges else { return }
|
||||
if isChannelEditor {
|
||||
guard !canonicalChannelName.isEmpty else { return }
|
||||
onSet(["action": "save", "name": canonicalChannelName, "description": textView.text ?? ""])
|
||||
} else {
|
||||
onSet(currentValue)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func editCanvasTapped() {
|
||||
guard canEditCanvas else { return }
|
||||
onSet(["action": "canvas", "name": currentValue, "description": textView.text ?? ""])
|
||||
}
|
||||
|
||||
private func removeCanvasTapped() {
|
||||
guard canEditCanvas && hasCanvas else { return }
|
||||
let alert = UIAlertController(
|
||||
title: "Remove canvas?",
|
||||
message: "This removes the canvas content for everyone in this channel.",
|
||||
preferredStyle: .alert
|
||||
)
|
||||
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
|
||||
alert.addAction(UIAlertAction(title: "Remove", style: .destructive) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
self.onSet(["action": "removeCanvas", "name": self.currentValue, "description": self.textView.text ?? ""])
|
||||
})
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
guard isChannelEditor && canEditCanvas else { return }
|
||||
if indexPath.section == 2 { editCanvasTapped() }
|
||||
if indexPath.section == 3 { removeCanvasTapped() }
|
||||
}
|
||||
|
||||
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
|
||||
setTapped()
|
||||
if isChannelEditor {
|
||||
textView.becomeFirstResponder()
|
||||
} else {
|
||||
setTapped()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
override func numberOfSections(in tableView: UITableView) -> Int { 1 }
|
||||
override func numberOfSections(in tableView: UITableView) -> Int { isChannelEditor ? (hasCanvas && canEditCanvas ? 4 : 3) : 1 }
|
||||
|
||||
override func tableView(
|
||||
_ tableView: UITableView,
|
||||
@@ -386,7 +491,8 @@ private final class NativeProfileTextEditorViewController:
|
||||
_ tableView: UITableView,
|
||||
heightForRowAt indexPath: IndexPath
|
||||
) -> CGFloat {
|
||||
multiline ? 132 : 52
|
||||
if isChannelEditor && indexPath.section >= 2 { return UITableView.automaticDimension }
|
||||
return (isChannelEditor ? indexPath.section == 1 : multiline) ? 132 : 52
|
||||
}
|
||||
|
||||
override func tableView(
|
||||
@@ -399,7 +505,29 @@ private final class NativeProfileTextEditorViewController:
|
||||
cell.layer.cornerRadius = containerCornerRadius
|
||||
cell.layer.cornerCurve = .continuous
|
||||
cell.clipsToBounds = true
|
||||
if multiline {
|
||||
if isChannelEditor && indexPath.section >= 2 {
|
||||
var content = cell.defaultContentConfiguration()
|
||||
content.textProperties.font = .preferredFont(forTextStyle: .body)
|
||||
if indexPath.section == 3 {
|
||||
content.text = "Remove Canvas"
|
||||
content.textProperties.color = .systemRed
|
||||
} else if hasCanvas {
|
||||
content.text = "Canvas"
|
||||
content.secondaryText = String(canvasContent.prefix(600))
|
||||
content.secondaryTextProperties.font = .preferredFont(forTextStyle: .body)
|
||||
content.secondaryTextProperties.numberOfLines = 4
|
||||
cell.accessoryType = canEditCanvas ? .disclosureIndicator : .none
|
||||
cell.accessibilityHint = canEditCanvas ? "Opens the canvas editor" : nil
|
||||
} else {
|
||||
content.text = canvasLoaded ? "Add Canvas" : "Retry loading canvas"
|
||||
content.textProperties.color = canEditCanvas ? .label : .secondaryLabel
|
||||
cell.accessoryType = canEditCanvas ? .disclosureIndicator : .none
|
||||
}
|
||||
cell.contentConfiguration = content
|
||||
cell.selectionStyle = canEditCanvas ? .default : .none
|
||||
cell.isUserInteractionEnabled = canEditCanvas
|
||||
cell.accessibilityTraits = canEditCanvas ? .button : .staticText
|
||||
} else if isChannelEditor ? indexPath.section == 1 : multiline {
|
||||
cell.contentView.addSubview(textView)
|
||||
textView.addSubview(placeholderLabel)
|
||||
NSLayoutConstraint.activate([
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:buzz/shared/theme/buzz_icons.dart';
|
||||
@@ -10,7 +12,54 @@ import '../../shared/widgets/app_list_card_item.dart';
|
||||
import '../../shared/widgets/avatar_image.dart';
|
||||
import '../../shared/widgets/buzz_loading_indicator.dart';
|
||||
import '../../shared/identity_names/identity_names_provider.dart';
|
||||
import '../../shared/profile/user_cache_provider.dart';
|
||||
import '../../shared/relay/relay.dart';
|
||||
import 'channel_management_provider.dart';
|
||||
import 'ios_add_members_sheet.dart';
|
||||
import '../../shared/widgets/modal_presentation.dart';
|
||||
|
||||
bool _nativePickerOpen = false;
|
||||
|
||||
/// Opens the platform member picker, retaining the Android sheet presentation.
|
||||
Future<bool?> showAddChannelMembersSheet({
|
||||
required BuildContext context,
|
||||
required String channelId,
|
||||
required Set<String> existingPubkeys,
|
||||
}) async {
|
||||
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.iOS) {
|
||||
if (_nativePickerOpen) return null;
|
||||
_nativePickerOpen = true;
|
||||
try {
|
||||
return await showGeneralDialog<bool>(
|
||||
context: context,
|
||||
barrierColor: Colors.transparent,
|
||||
transitionDuration: Duration.zero,
|
||||
pageBuilder: (_, _, _) => AddChannelMembersSheet(
|
||||
channelId: channelId,
|
||||
existingPubkeys: existingPubkeys,
|
||||
nativePresentation: true,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
_nativePickerOpen = false;
|
||||
}
|
||||
}
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
return showBuzzModalBottomSheet<bool>(
|
||||
context: context,
|
||||
title: 'Add members',
|
||||
isScrollControlled: true,
|
||||
showDragHandle: true,
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: 640,
|
||||
maxHeight: mediaQuery.size.height - mediaQuery.viewPadding.top - Grid.xs,
|
||||
),
|
||||
builder: (_) => AddChannelMembersSheet(
|
||||
channelId: channelId,
|
||||
existingPubkeys: existingPubkeys,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Searchable multi-select used to add people or agents to a channel.
|
||||
class AddChannelMembersSheet extends HookConsumerWidget {
|
||||
@@ -18,10 +67,12 @@ class AddChannelMembersSheet extends HookConsumerWidget {
|
||||
super.key,
|
||||
required this.channelId,
|
||||
required this.existingPubkeys,
|
||||
this.nativePresentation = false,
|
||||
});
|
||||
|
||||
final String channelId;
|
||||
final Set<String> existingPubkeys;
|
||||
final bool nativePresentation;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
@@ -31,6 +82,7 @@ class AddChannelMembersSheet extends HookConsumerWidget {
|
||||
final selectedUsers = useState<List<DirectoryUser>>([]);
|
||||
final isSubmitting = useState(false);
|
||||
final submitError = useState<String?>(null);
|
||||
final actions = useMemoized(() => ref.read(channelActionsProvider));
|
||||
|
||||
useEffect(() {
|
||||
final timer = Timer(const Duration(milliseconds: 250), () {
|
||||
@@ -101,14 +153,13 @@ class AddChannelMembersSheet extends HookConsumerWidget {
|
||||
isSubmitting.value = true;
|
||||
submitError.value = null;
|
||||
try {
|
||||
await ref
|
||||
.read(channelActionsProvider)
|
||||
.addMembers(
|
||||
channelId: channelId,
|
||||
pubkeys: submittedUsers.map((user) => user.pubkey).toList(),
|
||||
);
|
||||
await actions.addMembers(
|
||||
channelId: channelId,
|
||||
pubkeys: submittedUsers.map((user) => user.pubkey).toList(),
|
||||
);
|
||||
if (context.mounted) Navigator.of(context).pop(true);
|
||||
} on AddMembersException catch (error) {
|
||||
if (!context.mounted) return;
|
||||
final failedPubkeys = error.failures.keys
|
||||
.map((pubkey) => pubkey.toLowerCase())
|
||||
.toSet();
|
||||
@@ -124,12 +175,87 @@ class AddChannelMembersSheet extends HookConsumerWidget {
|
||||
];
|
||||
submitError.value = error.message;
|
||||
} catch (error) {
|
||||
if (!context.mounted) return;
|
||||
submitError.value = error.toString();
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
if (context.mounted) isSubmitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (nativePresentation) {
|
||||
final profiles = ref.watch(userCacheProvider);
|
||||
final auth = ref.watch(mediaGetAuthServiceProvider);
|
||||
final client = ref.watch(mediaHttpClientProvider);
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
String? avatarUrl(DirectoryUser user) =>
|
||||
profiles[user.pubkey]?.avatarUrl ?? user.avatarUrl;
|
||||
String avatarKey(DirectoryUser user) => jsonEncode([
|
||||
user.pubkey,
|
||||
avatarUrl(user),
|
||||
user.initial,
|
||||
user.isAgent,
|
||||
colors.primaryContainer.toARGB32(),
|
||||
colors.onPrimaryContainer.toARGB32(),
|
||||
]);
|
||||
Map<String, Object?> row(DirectoryUser user) => {
|
||||
'pubkey': user.pubkey,
|
||||
'name': labelFor(user),
|
||||
'detail': user.secondaryLabel,
|
||||
'agent': user.isAgent,
|
||||
'avatarKey': avatarKey(user),
|
||||
'initial': user.initial,
|
||||
'avatarBackground': colors.primaryContainer.toARGB32(),
|
||||
'avatarForeground': colors.onPrimaryContainer.toARGB32(),
|
||||
'selected': selectedPubkeys.contains(user.pubkey.toLowerCase()),
|
||||
};
|
||||
return IosAddMembersSheet(
|
||||
onAvatar: (pubkey, key) async {
|
||||
final user = choices
|
||||
.where((user) => user.pubkey == pubkey)
|
||||
.firstOrNull;
|
||||
if (user == null || avatarKey(user) != key) return null;
|
||||
return nativeAvatarImage(
|
||||
url: avatarUrl(user),
|
||||
initial: user.initial,
|
||||
isAgent: user.isAgent,
|
||||
background: colors.primaryContainer,
|
||||
foreground: colors.onPrimaryContainer,
|
||||
networkImage: (url) =>
|
||||
MediaImageProvider(url: url, auth: auth, client: client),
|
||||
);
|
||||
},
|
||||
state: {
|
||||
'query': query.value,
|
||||
'loading':
|
||||
directoryAsync.isLoading ||
|
||||
query.value.trim().toLowerCase() != normalizedQuery,
|
||||
'loadError': directoryAsync.hasError,
|
||||
'error': submitError.value,
|
||||
'submitting': isSubmitting.value,
|
||||
'selected': [for (final user in selectedUsers.value) row(user)],
|
||||
'users': [for (final user in availableUsers) row(user)],
|
||||
},
|
||||
onQuery: (value) {
|
||||
if (!isSubmitting.value) query.value = value;
|
||||
},
|
||||
onToggle: (pubkey) {
|
||||
final user = choices
|
||||
.where((user) => user.pubkey == pubkey)
|
||||
.firstOrNull;
|
||||
if (user != null) toggleUser(user);
|
||||
},
|
||||
onSubmit: () => unawaited(addSelectedMembers()),
|
||||
onRetry: () {
|
||||
if (normalizedQuery.isEmpty) {
|
||||
ref.invalidate(relayDirectoryUsersProvider);
|
||||
} else {
|
||||
ref.invalidate(relayDirectorySearchProvider(normalizedQuery));
|
||||
}
|
||||
},
|
||||
onClose: () => Navigator.of(context).pop(false),
|
||||
);
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
Grid.gutter,
|
||||
|
||||
@@ -216,26 +216,14 @@ class ChannelActionsSheet extends HookConsumerWidget {
|
||||
leading: const Icon(BuzzIcons.settings),
|
||||
title: const Text('Manage channel'),
|
||||
onTap: () async {
|
||||
final shouldClose =
|
||||
await showBuzzModalBottomSheet<bool>(
|
||||
context: context,
|
||||
title: 'Manage channel',
|
||||
isScrollControlled: true,
|
||||
showDragHandle: true,
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: 640,
|
||||
maxHeight:
|
||||
MediaQuery.sizeOf(context).height * 0.9,
|
||||
),
|
||||
builder: (_) => ManageChannelSheet(
|
||||
channel: currentChannel,
|
||||
canEditDetails:
|
||||
canManageLifecycle &&
|
||||
!currentChannel.isArchived,
|
||||
onChannelUpdated: (updated) =>
|
||||
displayedChannel.value = updated,
|
||||
),
|
||||
);
|
||||
final shouldClose = await showManageChannelSheet(
|
||||
context: context,
|
||||
channel: currentChannel,
|
||||
canEditDetails:
|
||||
canManageLifecycle && !currentChannel.isArchived,
|
||||
onChannelUpdated: (updated) =>
|
||||
displayedChannel.value = updated,
|
||||
);
|
||||
if (shouldClose == true && context.mounted) {
|
||||
Navigator.of(context).pop(true);
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ import 'message_actions.dart';
|
||||
import 'message_action_backdrop_state.dart';
|
||||
import 'message_long_press_region.dart';
|
||||
import 'message_content.dart';
|
||||
import 'message_mention_pill.dart';
|
||||
import 'message_skeleton_body.dart';
|
||||
import '../../shared/read_state/deferred_read_state_update.dart';
|
||||
import '../../shared/read_state/read_state_provider.dart';
|
||||
|
||||
@@ -28,18 +28,6 @@ class _SystemMessageRow extends HookConsumerWidget {
|
||||
final userCache = ref.watch(userCacheProvider);
|
||||
final sourceMessages = groupedMessages ?? [message];
|
||||
final groupedMembership = _membershipDisplayEvent(sourceMessages);
|
||||
final messageStyleAction = switch (systemEvent.type) {
|
||||
SystemEventType.channelCreated => 'created this channel',
|
||||
SystemEventType.huddleStarted => 'started a huddle',
|
||||
SystemEventType.huddleEnded => 'ended the huddle',
|
||||
_ => null,
|
||||
};
|
||||
final messageStyleActor = messageStyleAction == null
|
||||
? null
|
||||
: systemEvent.actorPubkey?.trim();
|
||||
final usesMessageStyleLayout =
|
||||
groupedMembership != null ||
|
||||
(messageStyleActor != null && messageStyleActor.isNotEmpty);
|
||||
|
||||
final identityNames = ref.watch(channelIdentityNamesProvider(channelId));
|
||||
String resolveLabel(String? pubkey) {
|
||||
@@ -50,6 +38,67 @@ class _SystemMessageRow extends HookConsumerWidget {
|
||||
return identityNames.labelFor(pubkey);
|
||||
}
|
||||
|
||||
final target = systemEvent.targetPubkey?.trim().toLowerCase();
|
||||
final targetIsAgent =
|
||||
ref.watch(agentMentionPubkeysProvider(channelId)).contains(target) ||
|
||||
userCache[target]?.ownerPubkey != null;
|
||||
final messageStyleAction = switch (systemEvent.type) {
|
||||
SystemEventType.memberRemoved => <InlineSpan>[
|
||||
const TextSpan(text: 'removed '),
|
||||
if (target == null || target.isEmpty)
|
||||
TextSpan(text: resolveLabel(target))
|
||||
else
|
||||
WidgetSpan(
|
||||
alignment: PlaceholderAlignment.baseline,
|
||||
baseline: TextBaseline.alphabetic,
|
||||
child: Semantics(
|
||||
container: true,
|
||||
label: resolveLabel(target),
|
||||
button: true,
|
||||
child: GestureDetector(
|
||||
onTap: () => showUserProfileSheet(
|
||||
context,
|
||||
target,
|
||||
names: channelIdentityNamesProvider(channelId),
|
||||
),
|
||||
child: ExcludeSemantics(
|
||||
child: MessageMentionPill(
|
||||
label: resolveLabel(target),
|
||||
isAgent: targetIsAgent,
|
||||
textStyle: _systemActionTextStyle(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const TextSpan(text: ' from the channel'),
|
||||
],
|
||||
SystemEventType.topicChanged => [
|
||||
TextSpan(
|
||||
text: describeChannelTextFieldChange('topic', systemEvent.topic),
|
||||
),
|
||||
],
|
||||
SystemEventType.purposeChanged => [
|
||||
TextSpan(
|
||||
text: describeChannelTextFieldChange('purpose', systemEvent.purpose),
|
||||
),
|
||||
],
|
||||
SystemEventType.channelCreated => [
|
||||
const TextSpan(text: 'created this channel'),
|
||||
],
|
||||
SystemEventType.huddleStarted => [
|
||||
const TextSpan(text: 'started a huddle'),
|
||||
],
|
||||
SystemEventType.huddleEnded => [const TextSpan(text: 'ended the huddle')],
|
||||
_ => null,
|
||||
};
|
||||
final messageStyleActor = messageStyleAction == null
|
||||
? null
|
||||
: systemEvent.actorPubkey?.trim();
|
||||
final usesMessageStyleLayout =
|
||||
groupedMembership != null ||
|
||||
(messageStyleActor != null && messageStyleActor.isNotEmpty);
|
||||
|
||||
final reactions = groupedMessages == null
|
||||
? message.reactions
|
||||
: _aggregateSystemMessageReactions(sourceMessages);
|
||||
@@ -138,7 +187,7 @@ class _SystemMessageRow extends HookConsumerWidget {
|
||||
createdAt: message.createdAt,
|
||||
resolveLabel: resolveLabel,
|
||||
userCache: userCache,
|
||||
actionSpans: [TextSpan(text: messageStyleAction)],
|
||||
actionSpans: messageStyleAction,
|
||||
)
|
||||
: Row(
|
||||
children: [
|
||||
|
||||
@@ -212,41 +212,21 @@ class ChannelDetailsPage extends HookConsumerWidget {
|
||||
);
|
||||
|
||||
Future<void> openAddMembers() async {
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
await showBuzzModalBottomSheet<bool>(
|
||||
await showAddChannelMembersSheet(
|
||||
context: context,
|
||||
title: 'Add members',
|
||||
isScrollControlled: true,
|
||||
showDragHandle: true,
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: 640,
|
||||
maxHeight:
|
||||
mediaQuery.size.height - mediaQuery.viewPadding.top - Grid.xs,
|
||||
),
|
||||
builder: (_) => AddChannelMembersSheet(
|
||||
channelId: resolvedChannel.id,
|
||||
existingPubkeys: {
|
||||
for (final member in members) member.pubkey.toLowerCase(),
|
||||
},
|
||||
),
|
||||
channelId: resolvedChannel.id,
|
||||
existingPubkeys: {
|
||||
for (final member in members) member.pubkey.toLowerCase(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> openManageChannel() async {
|
||||
final shouldClose = await showBuzzModalBottomSheet<bool>(
|
||||
final shouldClose = await showManageChannelSheet(
|
||||
context: context,
|
||||
title: 'Manage channel',
|
||||
isScrollControlled: true,
|
||||
showDragHandle: true,
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: 640,
|
||||
maxHeight: MediaQuery.sizeOf(context).height * 0.9,
|
||||
),
|
||||
builder: (_) => ManageChannelSheet(
|
||||
channel: resolvedChannel,
|
||||
canEditDetails: canEdit,
|
||||
onChannelUpdated: (updated) => displayedChannel.value = updated,
|
||||
),
|
||||
channel: resolvedChannel,
|
||||
canEditDetails: canEdit,
|
||||
onChannelUpdated: (updated) => displayedChannel.value = updated,
|
||||
);
|
||||
if (shouldClose == true && context.mounted) {
|
||||
Navigator.of(context).pop(true);
|
||||
|
||||
@@ -6,7 +6,7 @@ import 'package:buzz/shared/theme/buzz_icons.dart';
|
||||
import '../../shared/theme/theme.dart';
|
||||
import '../../shared/widgets/app_list.dart';
|
||||
import '../../shared/widgets/app_list_card.dart';
|
||||
import '../../shared/widgets/modal_presentation.dart';
|
||||
import '../../shared/widgets/confirmation_dialog.dart';
|
||||
import 'channel.dart';
|
||||
import 'channel_identity_names_provider.dart';
|
||||
import 'channel_management_provider.dart';
|
||||
@@ -93,22 +93,11 @@ class ChannelMemberProfileActions extends HookConsumerWidget {
|
||||
final label = ref
|
||||
.read(channelIdentityNamesProvider(channel.id))
|
||||
.labelFor(target);
|
||||
final confirmed = await showBuzzDialog<bool>(
|
||||
final confirmed = await showDestructiveConfirmation(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text('Remove from channel?'),
|
||||
content: Text('Remove $label from ${currentChannel.name}?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(true),
|
||||
child: const Text('Remove'),
|
||||
),
|
||||
],
|
||||
),
|
||||
title: 'Remove from channel?',
|
||||
message: 'Remove $label from ${currentChannel.name}?',
|
||||
confirmLabel: 'Remove',
|
||||
);
|
||||
if (!context.mounted || confirmed != true || !stillAllowed()) return;
|
||||
await ref
|
||||
|
||||
@@ -585,11 +585,14 @@ class _SettingsPageRoute extends PageRouteBuilder<void> {
|
||||
_SettingsPageRoute({
|
||||
required WidgetBuilder builder,
|
||||
required this.onTransitionProgress,
|
||||
bool? opaque,
|
||||
}) : super(
|
||||
pageBuilder: (context, animation, secondaryAnimation) =>
|
||||
builder(context),
|
||||
transitionsBuilder: _buildSettingsTransition,
|
||||
opaque: false,
|
||||
// Stop compositing Home's UIKit controls once Settings has settled.
|
||||
// Opaque routes still paint the previous page during both transitions.
|
||||
opaque: opaque ?? defaultTargetPlatform == TargetPlatform.iOS,
|
||||
allowSnapshotting: false,
|
||||
transitionDuration: const Duration(milliseconds: 150),
|
||||
reverseTransitionDuration: const Duration(milliseconds: 150),
|
||||
|
||||
@@ -40,7 +40,7 @@ class _CommunitySwitcherRoute extends _SettingsPageRoute {
|
||||
_CommunitySwitcherRoute({
|
||||
required super.builder,
|
||||
required super.onTransitionProgress,
|
||||
});
|
||||
}) : super(opaque: false);
|
||||
|
||||
bool flying = false;
|
||||
|
||||
|
||||
193
mobile/lib/features/channels/ios_add_members_sheet.dart
Normal file
193
mobile/lib/features/channels/ios_add_members_sheet.dart
Normal file
@@ -0,0 +1,193 @@
|
||||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../../shared/theme/theme.dart';
|
||||
|
||||
/// Hosts the native iOS member picker while Flutter owns its data and actions.
|
||||
class IosAddMembersSheet extends StatefulWidget {
|
||||
const IosAddMembersSheet({
|
||||
super.key,
|
||||
required this.state,
|
||||
required this.onAvatar,
|
||||
required this.onQuery,
|
||||
required this.onToggle,
|
||||
required this.onSubmit,
|
||||
required this.onRetry,
|
||||
required this.onClose,
|
||||
});
|
||||
|
||||
final Map<String, Object?> state;
|
||||
final Future<Uint8List?> Function(String pubkey, String avatarKey) onAvatar;
|
||||
final ValueChanged<String> onQuery;
|
||||
final ValueChanged<String> onToggle;
|
||||
final VoidCallback onSubmit;
|
||||
final VoidCallback onRetry;
|
||||
final VoidCallback onClose;
|
||||
|
||||
@override
|
||||
State<IosAddMembersSheet> createState() => _IosAddMembersSheetState();
|
||||
}
|
||||
|
||||
class _IosAddMembersSheetState extends State<IosAddMembersSheet> {
|
||||
static const _channel = MethodChannel('buzz/add_members_sheet');
|
||||
final _session = UniqueKey().toString();
|
||||
bool _presented = false;
|
||||
bool _closed = false;
|
||||
final _avatars = <String, Uint8List>{};
|
||||
final _pendingAvatars = <String, Future<Uint8List?>>{};
|
||||
final _avatarQueue =
|
||||
Queue<
|
||||
({
|
||||
String key,
|
||||
Future<Uint8List?> Function() load,
|
||||
Completer<Uint8List?> result,
|
||||
})
|
||||
>();
|
||||
int _activeAvatars = 0;
|
||||
|
||||
Future<Uint8List?> _avatar(String pubkey, String key) {
|
||||
if (_avatars[key] case final bytes?) return Future.value(bytes);
|
||||
if (_pendingAvatars[key] case final pending?) return pending;
|
||||
// Favor newly visible rows while bounding both work and retained results.
|
||||
if (_avatarQueue.length >= 32) {
|
||||
final dropped = _avatarQueue.removeFirst();
|
||||
_pendingAvatars.remove(dropped.key);
|
||||
dropped.result.complete(null);
|
||||
}
|
||||
final result = Completer<Uint8List?>();
|
||||
final load = widget.onAvatar;
|
||||
_pendingAvatars[key] = result.future;
|
||||
_avatarQueue.add((key: key, load: () => load(pubkey, key), result: result));
|
||||
_drainAvatars();
|
||||
return result.future;
|
||||
}
|
||||
|
||||
void _drainAvatars() {
|
||||
while (mounted &&
|
||||
!_closed &&
|
||||
_activeAvatars < 4 &&
|
||||
_avatarQueue.isNotEmpty) {
|
||||
final job = _avatarQueue.removeFirst();
|
||||
_activeAvatars++;
|
||||
unawaited(() async {
|
||||
Uint8List? bytes;
|
||||
try {
|
||||
bytes = await job.load();
|
||||
if (mounted && !_closed && bytes != null) {
|
||||
if (_avatars.length >= 64) _avatars.remove(_avatars.keys.first);
|
||||
_avatars[job.key] = bytes;
|
||||
}
|
||||
} catch (_) {
|
||||
// The native cell keeps its initial when media cannot be rendered.
|
||||
} finally {
|
||||
_pendingAvatars.remove(job.key);
|
||||
_activeAvatars--;
|
||||
job.result.complete(mounted && !_closed ? bytes : null);
|
||||
_drainAvatars();
|
||||
}
|
||||
}());
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_channel.setMethodCallHandler((call) async {
|
||||
if (!mounted || _closed) return null;
|
||||
final args = Map<Object?, Object?>.from(call.arguments as Map);
|
||||
if (args['session'] != _session) return null;
|
||||
switch (call.method) {
|
||||
case 'avatar':
|
||||
return _avatar(
|
||||
args['pubkey'] as String? ?? '',
|
||||
args['avatarKey'] as String? ?? '',
|
||||
);
|
||||
case 'query':
|
||||
widget.onQuery(args['value'] as String? ?? '');
|
||||
case 'toggle':
|
||||
widget.onToggle(args['pubkey'] as String? ?? '');
|
||||
case 'submit':
|
||||
widget.onSubmit();
|
||||
case 'retry':
|
||||
widget.onRetry();
|
||||
case 'closed':
|
||||
_closed = true;
|
||||
widget.onClose();
|
||||
}
|
||||
});
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _present());
|
||||
}
|
||||
|
||||
Map<String, Object?> get _state => {'session': _session, ...widget.state};
|
||||
|
||||
Future<void> _present() async {
|
||||
if (!mounted) return;
|
||||
final theme = utilitySurfaceThemeData(Theme.of(context));
|
||||
try {
|
||||
await _channel.invokeMethod<void>('present', {
|
||||
..._state,
|
||||
'dark': theme.brightness == Brightness.dark,
|
||||
'pageColor': theme.colorScheme.surface.toARGB32(),
|
||||
'rowColor': theme.colorScheme.surfaceContainerHighest.toARGB32(),
|
||||
});
|
||||
_presented = true;
|
||||
if (!mounted) {
|
||||
await _channel.invokeMethod<void>('dismiss', {'session': _session});
|
||||
return;
|
||||
}
|
||||
await _update();
|
||||
} on PlatformException {
|
||||
_failed();
|
||||
} on MissingPluginException {
|
||||
_failed();
|
||||
}
|
||||
}
|
||||
|
||||
void _failed() {
|
||||
if (!mounted || _closed) return;
|
||||
_closed = true;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text("Couldn't open Add members. Try again.")),
|
||||
);
|
||||
widget.onClose();
|
||||
}
|
||||
|
||||
Future<void> _update() async {
|
||||
if (!_presented || !mounted || _closed) return;
|
||||
try {
|
||||
await _channel.invokeMethod<void>('update', _state);
|
||||
} on PlatformException {
|
||||
_failed();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant IosAddMembersSheet oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
unawaited(_update());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_channel.setMethodCallHandler(null);
|
||||
for (final job in _avatarQueue) {
|
||||
job.result.complete(null);
|
||||
}
|
||||
_avatarQueue.clear();
|
||||
_avatars.clear();
|
||||
unawaited(
|
||||
_channel.invokeMethod<void>('dismiss', {'session': _session}).catchError((
|
||||
Object _,
|
||||
) {
|
||||
// The engine may already have disposed its native presentation.
|
||||
}),
|
||||
);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => const SizedBox.shrink();
|
||||
}
|
||||
@@ -1,349 +1,421 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../../shared/theme/theme.dart';
|
||||
import '../../shared/widgets/buzz_loading_indicator.dart';
|
||||
import '../../shared/widgets/modal_presentation.dart';
|
||||
import '../../shared/widgets/text_editor.dart';
|
||||
import 'channel.dart';
|
||||
import 'channel_management_provider.dart';
|
||||
|
||||
String _channelName(String value) =>
|
||||
value.trim().replaceFirst(RegExp(r'^#+'), '').trim();
|
||||
|
||||
/// Opens name and description directly in one full editing sheet.
|
||||
Future<bool?> showManageChannelSheet({
|
||||
required BuildContext context,
|
||||
required Channel channel,
|
||||
required bool canEditDetails,
|
||||
ValueChanged<Channel>? onChannelUpdated,
|
||||
}) async {
|
||||
final container = ProviderScope.containerOf(context, listen: false);
|
||||
final actions = container.read(channelActionsProvider);
|
||||
var nameDraft = channel.name;
|
||||
var descriptionDraft = channel.description;
|
||||
var retry = false;
|
||||
final canEditCanvas = channel.isMember && !channel.isArchived;
|
||||
bool canPresent() =>
|
||||
context.mounted && (ModalRoute.of(context)?.isCurrent ?? true);
|
||||
|
||||
if (defaultTargetPlatform == TargetPlatform.iOS) {
|
||||
final theme = utilitySurfaceThemeData(Theme.of(context));
|
||||
const bridge = MethodChannel('buzz/profile_text_editor');
|
||||
// Start the provider without delaying metadata editing on relay I/O.
|
||||
// A cold or failed canvas can be loaded/retried from its row.
|
||||
final cachedCanvas = container
|
||||
.read(channelCanvasProvider(channel.id))
|
||||
.asData;
|
||||
String? canvasContent = cachedCanvas?.value.content ?? '';
|
||||
var canvasLoaded = cachedCanvas != null;
|
||||
while (canPresent()) {
|
||||
Map<String, dynamic>? result;
|
||||
try {
|
||||
result = await bridge
|
||||
.invokeMapMethod<String, dynamic>('presentChannel', {
|
||||
'title': 'Edit channel',
|
||||
'initialValue': nameDraft,
|
||||
'description': descriptionDraft,
|
||||
'originalName': channel.name,
|
||||
'originalDescription': channel.description,
|
||||
'placeholder': 'Channel name',
|
||||
'multiline': false,
|
||||
'brightness': theme.brightness.name,
|
||||
'pageBackgroundArgb': theme.colorScheme.surface.toARGB32(),
|
||||
'containerBackgroundArgb': theme
|
||||
.colorScheme
|
||||
.surfaceContainerHighest
|
||||
.toARGB32(),
|
||||
'containerCornerRadius': Radii.container,
|
||||
'allowUnchangedSubmission': retry,
|
||||
'canEditDetails': canEditDetails,
|
||||
'canEditCanvas': canEditCanvas,
|
||||
'canvasContent': canvasContent,
|
||||
'canvasLoaded': canvasLoaded,
|
||||
});
|
||||
} on MissingPluginException {
|
||||
break;
|
||||
} on PlatformException {
|
||||
break;
|
||||
}
|
||||
if (result == null || !context.mounted || !canPresent()) return false;
|
||||
nameDraft = result['name'] as String? ?? nameDraft;
|
||||
descriptionDraft = result['description'] as String? ?? descriptionDraft;
|
||||
try {
|
||||
if (result['action'] == 'canvas') {
|
||||
if (canEditCanvas) {
|
||||
final saved = await _editCanvas(
|
||||
context,
|
||||
container,
|
||||
actions,
|
||||
channel.id,
|
||||
initialContent: canvasLoaded ? canvasContent : null,
|
||||
);
|
||||
if (saved != null) {
|
||||
canvasContent = saved;
|
||||
canvasLoaded = true;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (result['action'] == 'removeCanvas') {
|
||||
if (canEditCanvas &&
|
||||
canvasLoaded &&
|
||||
(canvasContent?.trim().isNotEmpty ?? false)) {
|
||||
await actions.setCanvas(channelId: channel.id, content: '');
|
||||
canvasContent = '';
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (result['action'] != 'save' || !canEditDetails) return false;
|
||||
final updated = await _saveDetails(
|
||||
actions,
|
||||
channel,
|
||||
nameDraft,
|
||||
descriptionDraft,
|
||||
);
|
||||
if (context.mounted) onChannelUpdated?.call(updated);
|
||||
return false;
|
||||
} catch (_) {
|
||||
if (!context.mounted || !canPresent()) return false;
|
||||
retry = true;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text("We couldn't save this change. Try again."),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!context.mounted || !canPresent()) return false;
|
||||
return showBuzzModalBottomSheet<bool>(
|
||||
context: context,
|
||||
title: 'Edit channel',
|
||||
isScrollControlled: true,
|
||||
useSafeArea: true,
|
||||
constraints: const BoxConstraints(maxWidth: 640),
|
||||
builder: (_) => SizedBox(
|
||||
height: MediaQuery.sizeOf(context).height * 0.9,
|
||||
child: ManageChannelSheet(
|
||||
channel: channel,
|
||||
canEditDetails: canEditDetails,
|
||||
onChannelUpdated: onChannelUpdated,
|
||||
initialName: nameDraft,
|
||||
initialDescription: descriptionDraft,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<Channel> _saveDetails(
|
||||
ChannelActions actions,
|
||||
Channel channel,
|
||||
String name,
|
||||
String description,
|
||||
) async {
|
||||
final canonicalName = _channelName(name);
|
||||
final trimmedDescription = description.trim();
|
||||
if (canonicalName.isEmpty) {
|
||||
throw const FormatException('Channel name is required.');
|
||||
}
|
||||
await actions.updateChannel(
|
||||
channelId: channel.id,
|
||||
name: canonicalName != channel.name.trim() ? canonicalName : null,
|
||||
description: trimmedDescription != channel.description.trim()
|
||||
? trimmedDescription
|
||||
: null,
|
||||
);
|
||||
return channel.copyWith(name: canonicalName, description: trimmedDescription);
|
||||
}
|
||||
|
||||
Future<String?> _editCanvas(
|
||||
BuildContext context,
|
||||
ProviderContainer container,
|
||||
ChannelActions actions,
|
||||
String channelId, {
|
||||
String? initialContent,
|
||||
}) async {
|
||||
if (initialContent == null &&
|
||||
container.read(channelCanvasProvider(channelId)).hasError) {
|
||||
container.invalidate(channelCanvasProvider(channelId));
|
||||
}
|
||||
final content =
|
||||
initialContent ??
|
||||
(await container
|
||||
.read(channelCanvasProvider(channelId).future)
|
||||
.timeout(const Duration(seconds: 5)))
|
||||
.content ??
|
||||
'';
|
||||
if (!context.mounted) return null;
|
||||
String? saved;
|
||||
await showTextEditor(
|
||||
context: context,
|
||||
title: 'Canvas',
|
||||
initialValue: content,
|
||||
hintText: 'Write your canvas content in Markdown…',
|
||||
multiline: true,
|
||||
onSave: (value) async {
|
||||
final trimmed = value.trim();
|
||||
await actions.setCanvas(channelId: channelId, content: trimmed);
|
||||
saved = trimmed;
|
||||
},
|
||||
);
|
||||
// A successful load is also useful when the editor is dismissed unchanged.
|
||||
return saved ?? content;
|
||||
}
|
||||
|
||||
/// Flutter fallback for the full channel editor.
|
||||
class ManageChannelSheet extends HookConsumerWidget {
|
||||
const ManageChannelSheet({
|
||||
super.key,
|
||||
required this.channel,
|
||||
this.canEditDetails = true,
|
||||
this.onChannelUpdated,
|
||||
this.initialName,
|
||||
this.initialDescription,
|
||||
});
|
||||
|
||||
final Channel channel;
|
||||
final bool canEditDetails;
|
||||
final ValueChanged<Channel>? onChannelUpdated;
|
||||
final String? initialName;
|
||||
final String? initialDescription;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final canvasAsync = ref.watch(channelCanvasProvider(channel.id));
|
||||
final nameController = useTextEditingController(text: channel.name);
|
||||
final descriptionController = useTextEditingController(
|
||||
text: channel.description,
|
||||
final name = useTextEditingController(text: initialName ?? channel.name);
|
||||
final description = useTextEditingController(
|
||||
text: initialDescription ?? channel.description,
|
||||
);
|
||||
useListenable(nameController);
|
||||
useListenable(descriptionController);
|
||||
final canvasController = useTextEditingController();
|
||||
final isEditingCanvas = useState(false);
|
||||
final isSavingCanvas = useState(false);
|
||||
final isSavingDetails = useState(false);
|
||||
final actionError = useState<String?>(null);
|
||||
|
||||
useEffect(() {
|
||||
final canvas = canvasAsync.asData?.value;
|
||||
if (!isEditingCanvas.value) {
|
||||
canvasController.text = canvas?.content ?? '';
|
||||
}
|
||||
return null;
|
||||
}, [canvasAsync.asData?.value.content, isEditingCanvas.value]);
|
||||
|
||||
final canonicalName = nameController.text
|
||||
.trim()
|
||||
.replaceFirst(RegExp(r'^#+'), '')
|
||||
.trim();
|
||||
final description = descriptionController.text.trim();
|
||||
final nameDirty = canonicalName != channel.name.trim();
|
||||
final descriptionDirty = description != channel.description.trim();
|
||||
final canSaveDetails =
|
||||
canEditDetails &&
|
||||
canonicalName.isNotEmpty &&
|
||||
(nameDirty || descriptionDirty) &&
|
||||
!isSavingDetails.value;
|
||||
useListenable(name);
|
||||
useListenable(description);
|
||||
final saving = useState(false);
|
||||
final openingCanvas = useState(false);
|
||||
final error = useState<String?>(null);
|
||||
final canvas = ref.watch(channelCanvasProvider(channel.id));
|
||||
final savedCanvas = useState<String?>(null);
|
||||
final canvasContent =
|
||||
savedCanvas.value ?? canvas.asData?.value.content ?? '';
|
||||
final canvasLoaded = savedCanvas.value != null || canvas.hasValue;
|
||||
final hasCanvas = canvasLoaded && canvasContent.trim().isNotEmpty;
|
||||
final canEditCanvas = channel.isMember && !channel.isArchived;
|
||||
// Keep actions bound to the community in which this editor was opened.
|
||||
final actions = useMemoized(() => ref.read(channelActionsProvider));
|
||||
final canonicalName = _channelName(name.text);
|
||||
final changed =
|
||||
canonicalName != channel.name.trim() ||
|
||||
description.text.trim() != channel.description.trim();
|
||||
final busy = saving.value || openingCanvas.value;
|
||||
|
||||
Future<void> saveDetails() async {
|
||||
if (!canSaveDetails) return;
|
||||
isSavingDetails.value = true;
|
||||
actionError.value = null;
|
||||
Future<void> save() async {
|
||||
if (busy || !canEditDetails || !changed || canonicalName.isEmpty) return;
|
||||
saving.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
await ref
|
||||
.read(channelActionsProvider)
|
||||
.updateChannel(
|
||||
channelId: channel.id,
|
||||
name: nameDirty ? canonicalName : null,
|
||||
description: descriptionDirty ? description : null,
|
||||
);
|
||||
final updated = channel.copyWith(
|
||||
name: canonicalName,
|
||||
description: description,
|
||||
final updated = await _saveDetails(
|
||||
actions,
|
||||
channel,
|
||||
name.text,
|
||||
description.text,
|
||||
);
|
||||
if (!context.mounted) return;
|
||||
onChannelUpdated?.call(updated);
|
||||
if (context.mounted) Navigator.of(context).pop(false);
|
||||
} catch (error) {
|
||||
actionError.value = error.toString();
|
||||
Navigator.of(context).pop(false);
|
||||
} catch (_) {
|
||||
if (context.mounted) {
|
||||
error.value = "We couldn't save this change. Try again.";
|
||||
}
|
||||
} finally {
|
||||
isSavingDetails.value = false;
|
||||
if (context.mounted) saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> saveCanvas() async {
|
||||
if (isSavingCanvas.value) return;
|
||||
isSavingCanvas.value = true;
|
||||
actionError.value = null;
|
||||
Future<void> editCanvas() async {
|
||||
if (busy) return;
|
||||
openingCanvas.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
await ref
|
||||
.read(channelActionsProvider)
|
||||
.setCanvas(
|
||||
channelId: channel.id,
|
||||
content: canvasController.text.trim(),
|
||||
);
|
||||
if (context.mounted) isEditingCanvas.value = false;
|
||||
} catch (error) {
|
||||
actionError.value = error.toString();
|
||||
final saved = await _editCanvas(
|
||||
context,
|
||||
ProviderScope.containerOf(context, listen: false),
|
||||
actions,
|
||||
channel.id,
|
||||
initialContent: canvasLoaded ? canvasContent : null,
|
||||
);
|
||||
if (context.mounted && saved != null) savedCanvas.value = saved;
|
||||
} catch (_) {
|
||||
if (context.mounted) {
|
||||
error.value = "Couldn't load the canvas. Try again.";
|
||||
}
|
||||
} finally {
|
||||
isSavingCanvas.value = false;
|
||||
if (context.mounted) openingCanvas.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
return Padding(
|
||||
Future<void> removeCanvas() async {
|
||||
if (busy || !canEditCanvas || !hasCanvas) return;
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Remove canvas?'),
|
||||
content: const Text(
|
||||
'This removes the canvas content for everyone in this channel.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text('Remove'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true || !context.mounted) return;
|
||||
openingCanvas.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
await actions.setCanvas(channelId: channel.id, content: '');
|
||||
if (context.mounted) savedCanvas.value = '';
|
||||
} catch (_) {
|
||||
if (context.mounted) {
|
||||
error.value = "Couldn't remove the canvas. Try again.";
|
||||
}
|
||||
} finally {
|
||||
if (context.mounted) openingCanvas.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
Grid.gutter,
|
||||
0,
|
||||
Grid.xxs,
|
||||
Grid.gutter,
|
||||
MediaQuery.viewInsetsOf(context).bottom + Grid.xs,
|
||||
),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
children: [
|
||||
_ManageChannelTextField(
|
||||
fieldKey: const ValueKey('manage-channel-name'),
|
||||
outlineKey: const ValueKey('manage-channel-name-outline'),
|
||||
controller: nameController,
|
||||
enabled: canEditDetails && !isSavingDetails.value,
|
||||
hintText: 'Channel name',
|
||||
textInputAction: TextInputAction.next,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
TextField(
|
||||
key: const ValueKey('manage-channel-name'),
|
||||
controller: name,
|
||||
autofocus: canEditDetails,
|
||||
enabled: canEditDetails && !busy,
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
textInputAction: TextInputAction.next,
|
||||
decoration: const InputDecoration(hintText: 'Channel name'),
|
||||
),
|
||||
const SizedBox(height: Grid.xs),
|
||||
TextField(
|
||||
key: const ValueKey('manage-channel-description'),
|
||||
controller: description,
|
||||
enabled: canEditDetails && !busy,
|
||||
minLines: 4,
|
||||
maxLines: 6,
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
textInputAction: TextInputAction.newline,
|
||||
decoration: const InputDecoration(hintText: 'Description'),
|
||||
),
|
||||
const SizedBox(height: Grid.xs),
|
||||
if (hasCanvas) ...[
|
||||
Material(
|
||||
color: context.colors.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(Radii.container),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: ListTile(
|
||||
key: const ValueKey('manage-channel-canvas'),
|
||||
title: const Text('Canvas'),
|
||||
subtitle: Text(
|
||||
canvasContent,
|
||||
maxLines: 4,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
enabled: canEditCanvas && !busy,
|
||||
onTap: canEditCanvas && !busy ? editCanvas : null,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: Grid.xs),
|
||||
_ManageChannelTextField(
|
||||
fieldKey: const ValueKey('manage-channel-description'),
|
||||
outlineKey: const ValueKey('manage-channel-description-outline'),
|
||||
controller: descriptionController,
|
||||
enabled: canEditDetails && !isSavingDetails.value,
|
||||
hintText: 'Description',
|
||||
minLines: 2,
|
||||
maxLines: 4,
|
||||
textInputAction: TextInputAction.newline,
|
||||
if (canEditCanvas)
|
||||
TextButton(
|
||||
key: const ValueKey('manage-channel-remove-canvas'),
|
||||
onPressed: busy ? null : removeCanvas,
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: context.colors.error,
|
||||
),
|
||||
child: const Text('Remove Canvas'),
|
||||
),
|
||||
] else
|
||||
FilledButton.tonal(
|
||||
key: const ValueKey('manage-channel-canvas'),
|
||||
onPressed: canEditCanvas && !busy ? editCanvas : null,
|
||||
child: Text(
|
||||
openingCanvas.value || (!canvasLoaded && canvas.isLoading)
|
||||
? 'Loading…'
|
||||
: canvasLoaded
|
||||
? 'Add Canvas'
|
||||
: 'Retry loading canvas',
|
||||
),
|
||||
),
|
||||
if (canonicalName.isEmpty) ...[
|
||||
const SizedBox(height: Grid.half),
|
||||
Text(
|
||||
'Channel name is required.',
|
||||
if (canonicalName.isEmpty || error.value != null) ...[
|
||||
const SizedBox(height: Grid.xxs),
|
||||
Semantics(
|
||||
liveRegion: true,
|
||||
child: Text(
|
||||
canonicalName.isEmpty
|
||||
? 'Channel name is required.'
|
||||
: error.value!,
|
||||
style: context.textTheme.bodySmall?.copyWith(
|
||||
color: context.colors.error,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: Grid.xs),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: FilledButton(
|
||||
key: const ValueKey('manage-channel-save-details'),
|
||||
onPressed: canSaveDetails ? saveDetails : null,
|
||||
child: Text(isSavingDetails.value ? 'Saving…' : 'Save changes'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: Grid.sm),
|
||||
_ManageChannelSection(
|
||||
label: 'Canvas',
|
||||
child: canvasAsync.when(
|
||||
data: (canvas) {
|
||||
if (isEditingCanvas.value) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
TextField(
|
||||
key: const ValueKey('manage-channel-canvas'),
|
||||
controller: canvasController,
|
||||
maxLines: 8,
|
||||
minLines: 6,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Write your canvas content in Markdown…',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: Grid.xxs),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: isSavingCanvas.value
|
||||
? null
|
||||
: () {
|
||||
isEditingCanvas.value = false;
|
||||
canvasController.text =
|
||||
canvas.content ?? '';
|
||||
},
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
const SizedBox(width: Grid.half),
|
||||
FilledButton(
|
||||
onPressed: isSavingCanvas.value
|
||||
? null
|
||||
: saveCanvas,
|
||||
child: Text(
|
||||
isSavingCanvas.value
|
||||
? 'Saving…'
|
||||
: 'Save canvas',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
canvas.content?.trim().isNotEmpty == true
|
||||
? canvas.content!
|
||||
: 'No canvas set for this channel.',
|
||||
style: context.textTheme.bodyMedium?.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: Grid.xxs),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: FilledButton.tonal(
|
||||
onPressed: canEditCanvas
|
||||
? () => isEditingCanvas.value = true
|
||||
: null,
|
||||
child: Text(
|
||||
canvas.content?.trim().isNotEmpty == true
|
||||
? 'Edit canvas'
|
||||
: 'Create canvas',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
loading: () => const Center(
|
||||
child: BuzzLoadingIndicator(
|
||||
size: 40,
|
||||
semanticLabel: 'Loading channel canvas',
|
||||
),
|
||||
),
|
||||
error: (error, _) => Text(
|
||||
'Couldn’t load the channel canvas.',
|
||||
style: context.textTheme.bodySmall?.copyWith(
|
||||
color: context.colors.error,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (actionError.value case final error?) ...[
|
||||
const SizedBox(height: Grid.xxs),
|
||||
Text(
|
||||
error,
|
||||
style: context.textTheme.bodySmall?.copyWith(
|
||||
color: context.colors.error,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: Grid.xs),
|
||||
FilledButton(
|
||||
key: const ValueKey('manage-channel-save-details'),
|
||||
onPressed:
|
||||
canEditDetails && changed && canonicalName.isNotEmpty && !busy
|
||||
? save
|
||||
: null,
|
||||
child: Text(saving.value ? 'Saving…' : 'Save'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ManageChannelTextField extends StatelessWidget {
|
||||
const _ManageChannelTextField({
|
||||
required this.fieldKey,
|
||||
required this.outlineKey,
|
||||
required this.controller,
|
||||
required this.enabled,
|
||||
required this.hintText,
|
||||
required this.textInputAction,
|
||||
this.minLines = 1,
|
||||
this.maxLines = 1,
|
||||
});
|
||||
|
||||
final Key fieldKey;
|
||||
final Key outlineKey;
|
||||
final TextEditingController controller;
|
||||
final bool enabled;
|
||||
final String hintText;
|
||||
final TextInputAction textInputAction;
|
||||
final int minLines;
|
||||
final int maxLines;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DecoratedBox(
|
||||
key: outlineKey,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: context.colors.outlineVariant.withValues(alpha: 0.8),
|
||||
),
|
||||
borderRadius: BorderRadius.circular(Radii.card),
|
||||
),
|
||||
child: Semantics(
|
||||
label: hintText,
|
||||
textField: true,
|
||||
child: TextField(
|
||||
key: fieldKey,
|
||||
controller: controller,
|
||||
enabled: enabled,
|
||||
minLines: minLines,
|
||||
maxLines: maxLines,
|
||||
style: context.textTheme.bodyLarge,
|
||||
decoration: InputDecoration(
|
||||
hintText: hintText,
|
||||
hintStyle: context.textTheme.bodyLarge?.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
border: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
isDense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: Grid.xs,
|
||||
vertical: Grid.twelve,
|
||||
),
|
||||
),
|
||||
textInputAction: textInputAction,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ManageChannelSection extends StatelessWidget {
|
||||
const _ManageChannelSection({required this.label, required this.child});
|
||||
|
||||
final String label;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: Grid.half, bottom: Grid.xxs),
|
||||
child: Text(
|
||||
label,
|
||||
style: context.textTheme.labelMedium?.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
Material(
|
||||
color: context.colors.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(Radii.card),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Padding(padding: const EdgeInsets.all(Grid.xs), child: child),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import '../../shared/widgets/sheet_action_section.dart';
|
||||
import '../../shared/widgets/avatar_image.dart';
|
||||
import '../../shared/widgets/buzz_loading_indicator.dart';
|
||||
import '../../shared/widgets/modal_presentation.dart';
|
||||
import '../../shared/widgets/confirmation_dialog.dart';
|
||||
import '../../shared/profile/user_cache_provider.dart';
|
||||
import '../../shared/profile/user_profile.dart';
|
||||
import '../profile/user_status.dart';
|
||||
@@ -358,25 +359,11 @@ class _MemberTile extends ConsumerWidget {
|
||||
),
|
||||
onTap: () async {
|
||||
Navigator.of(context).pop();
|
||||
final confirmed = await showBuzzDialog<bool>(
|
||||
final confirmed = await showDestructiveConfirmation(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Remove member'),
|
||||
content: Text('Remove $label from this channel?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: Text(
|
||||
'Remove',
|
||||
style: TextStyle(color: context.colors.error),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
title: 'Remove member',
|
||||
message: 'Remove $label from this channel?',
|
||||
confirmLabel: 'Remove',
|
||||
);
|
||||
if (confirmed == true) {
|
||||
await ref
|
||||
|
||||
@@ -224,7 +224,7 @@ final mentionCandidatesProvider = Provider.family
|
||||
return rankMentionCandidates(
|
||||
[
|
||||
for (final candidate in candidates)
|
||||
candidate.withContextLabel(names.resolve(candidate.pubkey)?.name),
|
||||
candidate.withContextLabel(names.labelFor(candidate.pubkey)),
|
||||
],
|
||||
args.query,
|
||||
viewer: currentPubkey,
|
||||
|
||||
@@ -32,6 +32,7 @@ import 'channels_provider.dart';
|
||||
import 'media_viewer_page.dart';
|
||||
import 'message_content/link_normalizer.dart';
|
||||
import 'message_media.dart';
|
||||
import 'message_mention_pill.dart';
|
||||
import 'message_gallery.dart';
|
||||
import 'message_gallery_frame.dart';
|
||||
import 'message_media_geometry.dart';
|
||||
@@ -874,7 +875,7 @@ class _MentionMd extends InlineMd {
|
||||
RegExp(r'\(([0-9a-f]{64})\)'),
|
||||
(m) => '(${m[1]!.substring(0, 8)}…${m[1]!.substring(60)})',
|
||||
);
|
||||
final pill = _MentionPill(
|
||||
final pill = MessageMentionPill(
|
||||
label: visibleLabel,
|
||||
semanticsLabel: fullLabel,
|
||||
isAgent: isAgent,
|
||||
@@ -891,68 +892,4 @@ class _MentionMd extends InlineMd {
|
||||
}
|
||||
}
|
||||
|
||||
class _MentionPill extends StatelessWidget {
|
||||
final String label;
|
||||
final String? semanticsLabel;
|
||||
final bool isAgent;
|
||||
final TextStyle? textStyle;
|
||||
|
||||
const _MentionPill({
|
||||
required this.label,
|
||||
this.semanticsLabel,
|
||||
required this.isAgent,
|
||||
this.textStyle,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final style =
|
||||
(textStyle ?? context.textTheme.bodyMedium)?.copyWith(
|
||||
color: context.colors.primary,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1,
|
||||
) ??
|
||||
TextStyle(
|
||||
color: context.colors.primary,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1,
|
||||
);
|
||||
final fontSize = style.fontSize ?? 16;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
Grid.half,
|
||||
Grid.quarter + 1,
|
||||
Grid.half,
|
||||
Grid.quarter,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: context.colors.primary.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(Radii.sm),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
if (isAgent) ...[
|
||||
Icon(
|
||||
BuzzIcons.bot,
|
||||
size: fontSize * 0.95,
|
||||
color: context.colors.primary,
|
||||
),
|
||||
const SizedBox(width: Grid.quarter + 1),
|
||||
] else
|
||||
Transform.translate(
|
||||
offset: const Offset(0, -Grid.quarter),
|
||||
child: Text('@', style: style),
|
||||
),
|
||||
Flexible(
|
||||
child: Text(label, style: style, semanticsLabel: semanticsLabel),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _markdownMentionName(String name) => name.replaceAll(' ', '\u00A0');
|
||||
|
||||
78
mobile/lib/features/channels/message_mention_pill.dart
Normal file
78
mobile/lib/features/channels/message_mention_pill.dart
Normal file
@@ -0,0 +1,78 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../shared/theme/buzz_icons.dart';
|
||||
|
||||
import '../../shared/theme/theme.dart';
|
||||
|
||||
/// The inline identity pill shared by chat mentions and channel actions.
|
||||
class MessageMentionPill extends StatelessWidget {
|
||||
/// Visible contextual identity name.
|
||||
final String label;
|
||||
|
||||
/// Full identity label for assistive technologies.
|
||||
final String? semanticsLabel;
|
||||
|
||||
/// Whether to use the bot icon instead of the @ prefix.
|
||||
final bool isAgent;
|
||||
|
||||
/// Typography inherited from the surrounding message.
|
||||
final TextStyle? textStyle;
|
||||
|
||||
/// Creates a chat mention with an agent icon or human @ prefix.
|
||||
const MessageMentionPill({
|
||||
super.key,
|
||||
required this.label,
|
||||
this.semanticsLabel,
|
||||
required this.isAgent,
|
||||
this.textStyle,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final style =
|
||||
(textStyle ?? context.textTheme.bodyMedium)?.copyWith(
|
||||
color: context.colors.primary,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1,
|
||||
) ??
|
||||
TextStyle(
|
||||
color: context.colors.primary,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1,
|
||||
);
|
||||
final fontSize = style.fontSize ?? 16;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
Grid.half,
|
||||
Grid.quarter + 1,
|
||||
Grid.half,
|
||||
Grid.quarter,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: context.colors.primary.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(Radii.sm),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
if (isAgent) ...[
|
||||
Icon(
|
||||
BuzzIcons.bot,
|
||||
size: fontSize * 0.95,
|
||||
color: context.colors.primary,
|
||||
),
|
||||
const SizedBox(width: Grid.quarter + 1),
|
||||
] else
|
||||
Transform.translate(
|
||||
offset: const Offset(0, -Grid.quarter),
|
||||
child: Text('@', style: style),
|
||||
),
|
||||
Flexible(
|
||||
child: Text(label, style: style, semanticsLabel: semanticsLabel),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -119,9 +119,9 @@ class SystemEvent {
|
||||
return '$actor removed $target from the channel';
|
||||
}(),
|
||||
SystemEventType.topicChanged =>
|
||||
'$actor ${_describeTextFieldChange('topic', topic)}',
|
||||
'$actor ${describeChannelTextFieldChange('topic', topic)}',
|
||||
SystemEventType.purposeChanged =>
|
||||
'$actor ${_describeTextFieldChange('purpose', purpose)}',
|
||||
'$actor ${describeChannelTextFieldChange('purpose', purpose)}',
|
||||
SystemEventType.channelCreated => '$actor created this channel',
|
||||
SystemEventType.channelArchived => '$actor archived this channel',
|
||||
SystemEventType.channelUnarchived => '$actor unarchived this channel',
|
||||
@@ -142,7 +142,7 @@ class SystemEvent {
|
||||
///
|
||||
/// Mirrors `describeChannelTextFieldChange` in
|
||||
/// `desktop/src/features/messages/lib/systemEventCopy.ts`.
|
||||
String _describeTextFieldChange(String field, String? value) {
|
||||
String describeChannelTextFieldChange(String field, String? value) {
|
||||
final trimmed = value?.trim();
|
||||
if (trimmed == null || trimmed.isEmpty) {
|
||||
return 'cleared the $field';
|
||||
|
||||
91
mobile/lib/features/profile/ios_presence_menu.dart
Normal file
91
mobile/lib/features/profile/ios_presence_menu.dart
Normal file
@@ -0,0 +1,91 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// Native iOS availability pill with a system menu owned by UIKit.
|
||||
class IosPresenceMenu extends StatefulWidget {
|
||||
const IosPresenceMenu({
|
||||
super.key,
|
||||
required this.presence,
|
||||
required this.label,
|
||||
required this.foreground,
|
||||
required this.background,
|
||||
required this.onSelected,
|
||||
});
|
||||
|
||||
final String presence;
|
||||
final String label;
|
||||
final Color foreground;
|
||||
final Color background;
|
||||
final ValueChanged<String> onSelected;
|
||||
|
||||
@override
|
||||
State<IosPresenceMenu> createState() => _IosPresenceMenuState();
|
||||
}
|
||||
|
||||
class _IosPresenceMenuState extends State<IosPresenceMenu> {
|
||||
MethodChannel? _channel;
|
||||
|
||||
Map<String, Object> _configuration(BuildContext context) => {
|
||||
'presence': widget.presence,
|
||||
'label': widget.label,
|
||||
'foreground': widget.foreground.toARGB32(),
|
||||
'background': widget.background.toARGB32(),
|
||||
'dark': Theme.of(context).brightness == Brightness.dark,
|
||||
'fontSize': MediaQuery.textScalerOf(context).scale(15),
|
||||
};
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant IosPresenceMenu oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
unawaited(_channel?.invokeMethod<void>('update', _configuration(context)));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_channel?.setMethodCallHandler(null);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fontSize = MediaQuery.textScalerOf(context).scale(15);
|
||||
final text = TextPainter(
|
||||
text: TextSpan(
|
||||
text: widget.label,
|
||||
style: TextStyle(fontSize: fontSize, fontWeight: FontWeight.w500),
|
||||
),
|
||||
textDirection: Directionality.of(context),
|
||||
)..layout();
|
||||
final width = text.width + 32;
|
||||
final height = (fontSize + 24).clamp(44.0, double.infinity);
|
||||
text.dispose();
|
||||
return SizedBox(
|
||||
key: const ValueKey('settings-presence-target'),
|
||||
width: width,
|
||||
height: height,
|
||||
child: UiKitView(
|
||||
key: const ValueKey('settings-presence-menu'),
|
||||
viewType: 'buzz/presence_menu',
|
||||
creationParams: _configuration(context),
|
||||
creationParamsCodec: const StandardMessageCodec(),
|
||||
onPlatformViewCreated: (id) {
|
||||
final channel = MethodChannel('buzz/presence_menu/$id');
|
||||
_channel = channel;
|
||||
channel.setMethodCallHandler((call) async {
|
||||
if (!mounted || call.method != 'selected') return;
|
||||
final value = call.arguments;
|
||||
if (value is String &&
|
||||
const ['online', 'away', 'offline'].contains(value)) {
|
||||
widget.onSelected(value);
|
||||
}
|
||||
});
|
||||
unawaited(
|
||||
channel.invokeMethod<void>('update', _configuration(context)),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,78 +1,4 @@
|
||||
import 'package:flutter/services.dart';
|
||||
import '../../shared/widgets/ios_text_editor.dart';
|
||||
|
||||
import '../../shared/theme/theme.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<String?> present({
|
||||
required String title,
|
||||
required String initialValue,
|
||||
required String placeholder,
|
||||
required bool multiline,
|
||||
required Brightness brightness,
|
||||
required Color pageBackgroundColor,
|
||||
required Color containerBackgroundColor,
|
||||
bool allowUnchangedSubmission = false,
|
||||
}) => _channel.invokeMethod<String>('present', {
|
||||
'title': title,
|
||||
'initialValue': initialValue,
|
||||
'placeholder': placeholder,
|
||||
'multiline': multiline,
|
||||
'brightness': brightness.name,
|
||||
'pageBackgroundArgb': pageBackgroundColor.toARGB32(),
|
||||
'containerBackgroundArgb': containerBackgroundColor.toARGB32(),
|
||||
'containerCornerRadius': Radii.container,
|
||||
'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<void> presentUntilSaved({
|
||||
required String title,
|
||||
required String initialValue,
|
||||
required String placeholder,
|
||||
required bool multiline,
|
||||
required Brightness brightness,
|
||||
required Color pageBackgroundColor,
|
||||
required Color containerBackgroundColor,
|
||||
required Future<void> 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,
|
||||
pageBackgroundColor: pageBackgroundColor,
|
||||
containerBackgroundColor: containerBackgroundColor,
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The shared native text editor used by profile fields.
|
||||
typedef IosProfileTextEditor = IosTextEditor;
|
||||
|
||||
@@ -1,15 +1,8 @@
|
||||
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 '../../shared/widgets/text_editor.dart';
|
||||
import 'profile_provider.dart';
|
||||
|
||||
/// Opens the current user's display-name editor from a profile action surface.
|
||||
@@ -93,141 +86,13 @@ Future<void> _showProfileTextEditor({
|
||||
required String hintText,
|
||||
required Future<void> Function(String value) onSave,
|
||||
bool multiline = false,
|
||||
}) async {
|
||||
if (defaultTargetPlatform == TargetPlatform.iOS) {
|
||||
final sheetTheme = utilitySurfaceThemeData(Theme.of(context));
|
||||
try {
|
||||
await IosProfileTextEditor.presentUntilSaved(
|
||||
title: title,
|
||||
initialValue: initialValue,
|
||||
placeholder: hintText,
|
||||
multiline: multiline,
|
||||
brightness: sheetTheme.brightness,
|
||||
pageBackgroundColor: sheetTheme.colorScheme.surface,
|
||||
containerBackgroundColor:
|
||||
sheetTheme.colorScheme.surfaceContainerHighest,
|
||||
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<void>(
|
||||
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<void> Function(String value) onSave;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final controller = useTextEditingController(text: initialValue);
|
||||
useListenable(controller);
|
||||
final isSaving = useState(false);
|
||||
final error = useState<String?>(null);
|
||||
final hasChanges = controller.text.trim() != initialValue.trim();
|
||||
|
||||
Future<void> 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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}) => showTextEditor(
|
||||
context: context,
|
||||
title: title,
|
||||
initialValue: initialValue,
|
||||
hintText: hintText,
|
||||
onSave: onSave,
|
||||
multiline: multiline,
|
||||
keyPrefix: 'profile-field',
|
||||
shouldRetryOnError: (error) => error is! ProfileCommunityChangedException,
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
@@ -16,6 +17,7 @@ import '../../shared/widgets/avatar_image.dart';
|
||||
import '../../shared/widgets/anchored_popover_menu.dart';
|
||||
import '../../shared/widgets/progressive_animated_avatar.dart';
|
||||
import 'profile_provider.dart';
|
||||
import 'ios_presence_menu.dart';
|
||||
import 'set_status_sheet.dart';
|
||||
import 'user_status_provider.dart';
|
||||
|
||||
@@ -247,6 +249,16 @@ class _PresencePill extends StatelessWidget {
|
||||
};
|
||||
final label = _presenceLabel(effectivePresence);
|
||||
|
||||
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.iOS) {
|
||||
return IosPresenceMenu(
|
||||
presence: effectivePresence,
|
||||
label: label,
|
||||
foreground: foregroundColor,
|
||||
background: backgroundColor,
|
||||
onSelected: onSelected,
|
||||
);
|
||||
}
|
||||
|
||||
return Builder(
|
||||
builder: (buttonContext) => Semantics(
|
||||
button: true,
|
||||
|
||||
@@ -117,8 +117,7 @@ class UserProfileSheet extends HookConsumerWidget {
|
||||
// never placed on the clipboard.
|
||||
final npub = fullNpub(pubkey);
|
||||
|
||||
// The contextual label from the opening surface, so the sheet names the
|
||||
// identity exactly as the row that was tapped.
|
||||
// Keep the opening surface's comparison context and qualified identity.
|
||||
final opener = names;
|
||||
final displayName =
|
||||
(opener != null ? ref.watch(opener) : watchIdentityNames(ref, {pk}))
|
||||
|
||||
@@ -71,71 +71,68 @@ class SettingsPage extends HookConsumerWidget {
|
||||
bottomHeight: Grid.xxs,
|
||||
);
|
||||
|
||||
return FrostedScaffold(
|
||||
useUtilitySurfaceTheme: true,
|
||||
appBar: FrostedAppBar(
|
||||
nativeTitle: 'Settings',
|
||||
nativeLeading: IosNavigationAction(
|
||||
label: 'Close settings',
|
||||
symbol: 'xmark',
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
automaticallyImplyLeading: false,
|
||||
horizontalInset: Grid.gutter,
|
||||
showBottomDivider: false,
|
||||
leading: Theme.of(context).platform == TargetPlatform.iOS
|
||||
? IosGlassNavigationButton(
|
||||
key: const ValueKey('settings-ios-glass-close'),
|
||||
icon: IosGlassNavigationIcon.close,
|
||||
semanticLabel: 'Close settings',
|
||||
onPressed: () {
|
||||
unawaited(HapticFeedback.lightImpact());
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
foregroundColor: navigationPrimaryForeground(context),
|
||||
)
|
||||
: SizedBox(
|
||||
width: Grid.xl,
|
||||
height: Grid.xl,
|
||||
child: IconButton(
|
||||
tooltip: 'Close settings',
|
||||
// Keep feedback local even while Home is mounted during the transition.
|
||||
return ScaffoldMessenger(
|
||||
child: FrostedScaffold(
|
||||
useUtilitySurfaceTheme: true,
|
||||
appBar: FrostedAppBar(
|
||||
nativeTitle: 'Settings',
|
||||
nativeLeading: IosNavigationAction(
|
||||
label: 'Close settings',
|
||||
symbol: 'xmark',
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
automaticallyImplyLeading: false,
|
||||
horizontalInset: Grid.gutter,
|
||||
showBottomDivider: false,
|
||||
leading: Theme.of(context).platform == TargetPlatform.iOS
|
||||
? IosGlassNavigationButton(
|
||||
key: const ValueKey('settings-ios-glass-close'),
|
||||
icon: IosGlassNavigationIcon.close,
|
||||
semanticLabel: 'Close settings',
|
||||
onPressed: () {
|
||||
unawaited(HapticFeedback.lightImpact());
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
color: navigationPrimaryForeground(context),
|
||||
icon: const Icon(BuzzIcons.x),
|
||||
foregroundColor: navigationPrimaryForeground(context),
|
||||
)
|
||||
: SizedBox(
|
||||
width: Grid.xl,
|
||||
height: Grid.xl,
|
||||
child: IconButton(
|
||||
tooltip: 'Close settings',
|
||||
onPressed: () {
|
||||
unawaited(HapticFeedback.lightImpact());
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
color: navigationPrimaryForeground(context),
|
||||
icon: const Icon(BuzzIcons.x),
|
||||
),
|
||||
),
|
||||
bottomHeight: Grid.xxs,
|
||||
bottom: const SizedBox.expand(),
|
||||
),
|
||||
body: ListView(
|
||||
padding: EdgeInsets.only(top: topSectionHeight, bottom: Grid.xs),
|
||||
children: [
|
||||
profileHeader,
|
||||
_StatusSection(onSetStatus: onSetStatus),
|
||||
_ProfileSection(
|
||||
profileEditPageBuilder: profileEditPageBuilder,
|
||||
onEditDisplayName: onEditDisplayName,
|
||||
onEditProfileDescription: onEditProfileDescription,
|
||||
),
|
||||
const _NotificationsSection(),
|
||||
_ConnectionSection(
|
||||
identityRecoveryPageBuilder: identityRecoveryPageBuilder,
|
||||
),
|
||||
if (packageInfo.hasData)
|
||||
_VersionFooter(
|
||||
version: packageInfo.data!.version,
|
||||
buildNumber: packageInfo.data!.buildNumber,
|
||||
),
|
||||
bottomHeight: Grid.xxs,
|
||||
bottom: const SizedBox.expand(),
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: EdgeInsets.only(top: topSectionHeight, bottom: Grid.xs),
|
||||
children: [
|
||||
profileHeader,
|
||||
_StatusSection(onSetStatus: onSetStatus),
|
||||
_ProfileSection(
|
||||
profileEditPageBuilder: profileEditPageBuilder,
|
||||
onEditDisplayName: onEditDisplayName,
|
||||
onEditProfileDescription: onEditProfileDescription,
|
||||
),
|
||||
const _NotificationsSection(),
|
||||
_ConnectionSection(
|
||||
identityRecoveryPageBuilder: identityRecoveryPageBuilder,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (packageInfo.hasData)
|
||||
_VersionFooter(
|
||||
version: packageInfo.data!.version,
|
||||
buildNumber: packageInfo.data!.buildNumber,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -161,8 +161,9 @@ class IdentityNames {
|
||||
});
|
||||
}
|
||||
|
||||
/// The display label for [pubkey]. A malformed key is outside the naming
|
||||
/// contract: it keeps its plain known name and is never disambiguated.
|
||||
/// The unambiguous display label for [pubkey], including any qualifier
|
||||
/// needed to distinguish identities with the same name and owner.
|
||||
/// A malformed key keeps its plain known name and is never disambiguated.
|
||||
String labelFor(String pubkey) =>
|
||||
resolve(pubkey)?.name ?? _plainName(pubkey.toLowerCase());
|
||||
|
||||
|
||||
78
mobile/lib/shared/widgets/ios_text_editor.dart
Normal file
78
mobile/lib/shared/widgets/ios_text_editor.dart
Normal file
@@ -0,0 +1,78 @@
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../theme/theme.dart';
|
||||
|
||||
/// Opens the native iOS form used to edit a single text field.
|
||||
class IosTextEditor {
|
||||
IosTextEditor._();
|
||||
|
||||
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<String?> present({
|
||||
required String title,
|
||||
required String initialValue,
|
||||
required String placeholder,
|
||||
required bool multiline,
|
||||
required Brightness brightness,
|
||||
required Color pageBackgroundColor,
|
||||
required Color containerBackgroundColor,
|
||||
bool allowUnchangedSubmission = false,
|
||||
}) => _channel.invokeMethod<String>('present', {
|
||||
'title': title,
|
||||
'initialValue': initialValue,
|
||||
'placeholder': placeholder,
|
||||
'multiline': multiline,
|
||||
'brightness': brightness.name,
|
||||
'pageBackgroundArgb': pageBackgroundColor.toARGB32(),
|
||||
'containerBackgroundArgb': containerBackgroundColor.toARGB32(),
|
||||
'containerCornerRadius': Radii.container,
|
||||
'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<void> presentUntilSaved({
|
||||
required String title,
|
||||
required String initialValue,
|
||||
required String placeholder,
|
||||
required bool multiline,
|
||||
required Brightness brightness,
|
||||
required Color pageBackgroundColor,
|
||||
required Color containerBackgroundColor,
|
||||
required Future<void> 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,
|
||||
pageBackgroundColor: pageBackgroundColor,
|
||||
containerBackgroundColor: containerBackgroundColor,
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
167
mobile/lib/shared/widgets/text_editor.dart
Normal file
167
mobile/lib/shared/widgets/text_editor.dart
Normal file
@@ -0,0 +1,167 @@
|
||||
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 '../theme/theme.dart';
|
||||
import 'modal_presentation.dart';
|
||||
import 'ios_text_editor.dart';
|
||||
|
||||
/// Opens the shared native iOS text form, with a Flutter sheet fallback.
|
||||
Future<void> showTextEditor({
|
||||
required BuildContext context,
|
||||
required String title,
|
||||
required String initialValue,
|
||||
required String hintText,
|
||||
required Future<void> Function(String value) onSave,
|
||||
bool multiline = false,
|
||||
String keyPrefix = 'text-editor',
|
||||
bool Function(Object error)? shouldRetryOnError,
|
||||
}) async {
|
||||
if (defaultTargetPlatform == TargetPlatform.iOS) {
|
||||
final sheetTheme = utilitySurfaceThemeData(Theme.of(context));
|
||||
try {
|
||||
await IosTextEditor.presentUntilSaved(
|
||||
title: title,
|
||||
initialValue: initialValue,
|
||||
placeholder: hintText,
|
||||
multiline: multiline,
|
||||
brightness: sheetTheme.brightness,
|
||||
pageBackgroundColor: sheetTheme.colorScheme.surface,
|
||||
containerBackgroundColor:
|
||||
sheetTheme.colorScheme.surfaceContainerHighest,
|
||||
onSave: onSave,
|
||||
shouldRetryOnError: shouldRetryOnError,
|
||||
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<void>(
|
||||
context: context,
|
||||
title: title,
|
||||
isScrollControlled: true,
|
||||
requestFocus: true,
|
||||
builder: (_) => _TextEditSheet(
|
||||
initialValue: initialValue,
|
||||
hintText: hintText,
|
||||
multiline: multiline,
|
||||
onSave: onSave,
|
||||
shouldRetryOnError: shouldRetryOnError,
|
||||
keyPrefix: keyPrefix,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showSaveError(BuildContext context) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text("We couldn't save this change. Try again.")),
|
||||
);
|
||||
}
|
||||
|
||||
class _TextEditSheet extends HookWidget {
|
||||
const _TextEditSheet({
|
||||
required this.initialValue,
|
||||
required this.hintText,
|
||||
required this.multiline,
|
||||
required this.onSave,
|
||||
this.shouldRetryOnError,
|
||||
required this.keyPrefix,
|
||||
});
|
||||
|
||||
final String keyPrefix;
|
||||
final String initialValue;
|
||||
final String hintText;
|
||||
final bool multiline;
|
||||
final Future<void> Function(String value) onSave;
|
||||
final bool Function(Object error)? shouldRetryOnError;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final controller = useTextEditingController(text: initialValue);
|
||||
useListenable(controller);
|
||||
final isSaving = useState(false);
|
||||
final error = useState<String?>(null);
|
||||
final hasChanges = controller.text.trim() != initialValue.trim();
|
||||
|
||||
Future<void> save() async {
|
||||
if (!hasChanges || isSaving.value) return;
|
||||
isSaving.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
await onSave(controller.text);
|
||||
if (context.mounted) Navigator.of(context).pop();
|
||||
} catch (saveError) {
|
||||
if (!context.mounted) return;
|
||||
if (shouldRetryOnError?.call(saveError) == false) {
|
||||
Navigator.of(context).pop();
|
||||
return;
|
||||
}
|
||||
error.value = "We couldn't save this change. Try again.";
|
||||
} finally {
|
||||
if (context.mounted) isSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
return SafeArea(
|
||||
top: false,
|
||||
child: SingleChildScrollView(
|
||||
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: ValueKey('$keyPrefix-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: ValueKey('$keyPrefix-save'),
|
||||
onPressed: hasChanges && !isSaving.value ? save : null,
|
||||
child: Text(isSaving.value ? 'Saving…' : 'Save'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,8 @@ import 'package:buzz/shared/mentions/agent_identity_provider.dart';
|
||||
import 'package:buzz/shared/relay/relay.dart';
|
||||
import 'package:buzz/shared/theme/theme.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
@@ -61,6 +63,8 @@ Widget _modalApp({
|
||||
required Channel channel,
|
||||
required Future<List<ChannelMember>> Function() loadMembers,
|
||||
required ChannelActions Function(Ref ref) createChannelActions,
|
||||
String? canvasContent,
|
||||
Future<ChannelCanvas>? pendingCanvas,
|
||||
}) => ProviderScope(
|
||||
overrides: [
|
||||
currentPubkeyProvider.overrideWith((ref) => _currentPubkey),
|
||||
@@ -72,11 +76,13 @@ Widget _modalApp({
|
||||
const AsyncValue.data(<String, String>{}),
|
||||
),
|
||||
channelCanvasProvider(channel.id).overrideWith(
|
||||
(ref) async => const ChannelCanvas(
|
||||
content: null,
|
||||
updatedAt: null,
|
||||
authorPubkey: null,
|
||||
),
|
||||
(ref) =>
|
||||
pendingCanvas ??
|
||||
ChannelCanvas(
|
||||
content: canvasContent,
|
||||
updatedAt: null,
|
||||
authorPubkey: null,
|
||||
),
|
||||
),
|
||||
channelActionsProvider.overrideWith(createChannelActions),
|
||||
],
|
||||
@@ -350,7 +356,7 @@ void main() {
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.descendant(of: manageSheet, matching: find.text('Canvas')),
|
||||
find.descendant(of: manageSheet, matching: find.text('Add Canvas')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
@@ -396,15 +402,18 @@ void main() {
|
||||
await tester.tap(find.text('Manage channel'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
tester.getSize(find.byType(BottomSheet).last).height,
|
||||
greaterThan(480),
|
||||
);
|
||||
await tester.enterText(
|
||||
find.byKey(const ValueKey('manage-channel-name')),
|
||||
'renamed',
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.tap(find.byKey(const ValueKey('manage-channel-save-details')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.widgetWithText(ListTile, 'Manage channel'), findsOneWidget);
|
||||
await tester.tap(find.widgetWithText(ListTile, 'Manage channel'));
|
||||
await tester.tap(find.text('Manage channel'));
|
||||
await tester.pumpAndSettle();
|
||||
final nameField = tester.widget<TextField>(
|
||||
find.byKey(const ValueKey('manage-channel-name')),
|
||||
@@ -412,6 +421,337 @@ void main() {
|
||||
expect(nameField.controller?.text, 'renamed');
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'native channel editor shares the profile form and retains failed drafts',
|
||||
(tester) async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
||||
const bridge = MethodChannel('buzz/profile_text_editor');
|
||||
final presentations = <Map<Object?, Object?>>[];
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(bridge, (call) async {
|
||||
presentations.add(
|
||||
Map<Object?, Object?>.from(call.arguments as Map),
|
||||
);
|
||||
expect(call.method, 'presentChannel');
|
||||
return {
|
||||
'action': 'save',
|
||||
'name': 'renamed',
|
||||
'description': 'Updated description',
|
||||
};
|
||||
});
|
||||
addTearDown(
|
||||
() => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(bridge, null),
|
||||
);
|
||||
var attempts = 0;
|
||||
await tester.pumpWidget(
|
||||
_modalApp(
|
||||
channel: _channel(),
|
||||
loadMembers: () async => [
|
||||
ChannelMember(
|
||||
pubkey: _currentPubkey,
|
||||
role: 'owner',
|
||||
joinedAt: DateTime(2025),
|
||||
),
|
||||
],
|
||||
createChannelActions: (ref) => _FakeChannelActions(
|
||||
ref,
|
||||
onUpdateChannel: (_, name, description) async {
|
||||
attempts++;
|
||||
if (attempts == 1) throw Exception('offline');
|
||||
expect(name, 'renamed');
|
||||
expect(description, 'Updated description');
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Open actions'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Manage channel'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(presentations, hasLength(2));
|
||||
expect(presentations.first['title'], 'Edit channel');
|
||||
expect(presentations.first['multiline'], false);
|
||||
expect(presentations.last['initialValue'], 'renamed');
|
||||
expect(presentations.last['allowUnchangedSubmission'], true);
|
||||
expect(presentations.last['description'], 'Updated description');
|
||||
expect(find.byType(ManageChannelSheet), findsNothing);
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('native metadata editor opens before a cold canvas finishes', (
|
||||
tester,
|
||||
) async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
||||
final canvas = Completer<ChannelCanvas>();
|
||||
const bridge = MethodChannel('buzz/profile_text_editor');
|
||||
var opened = false;
|
||||
var saved = false;
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(bridge, (call) async {
|
||||
expect(canvas.isCompleted, isFalse);
|
||||
expect((call.arguments as Map)['canvasLoaded'], isFalse);
|
||||
opened = true;
|
||||
return {
|
||||
'action': 'save',
|
||||
'name': 'renamed',
|
||||
'description': 'New description',
|
||||
};
|
||||
});
|
||||
addTearDown(
|
||||
() => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(bridge, null),
|
||||
);
|
||||
await tester.pumpWidget(
|
||||
_modalApp(
|
||||
channel: _channel(),
|
||||
pendingCanvas: canvas.future,
|
||||
loadMembers: () async => [
|
||||
ChannelMember(
|
||||
pubkey: _currentPubkey,
|
||||
role: 'owner',
|
||||
joinedAt: DateTime(2025),
|
||||
),
|
||||
],
|
||||
createChannelActions: (ref) => _FakeChannelActions(
|
||||
ref,
|
||||
onUpdateChannel: (_, name, description) async {
|
||||
saved = true;
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Open actions'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Manage channel'));
|
||||
await tester.pump();
|
||||
expect(opened, isTrue);
|
||||
expect(saved, isTrue);
|
||||
canvas.complete(
|
||||
const ChannelCanvas(content: null, updatedAt: null, authorPubkey: null),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
});
|
||||
|
||||
testWidgets('native canvas round trip retains both channel drafts', (
|
||||
tester,
|
||||
) async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
||||
const bridge = MethodChannel('buzz/profile_text_editor');
|
||||
var channelPresentations = 0;
|
||||
var savedDetails = false;
|
||||
var savedCanvas = false;
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(bridge, (call) async {
|
||||
final args = Map<Object?, Object?>.from(call.arguments as Map);
|
||||
if (call.method == 'present') {
|
||||
expect(args['title'], 'Canvas');
|
||||
expect(args['multiline'], true);
|
||||
return 'New canvas';
|
||||
}
|
||||
expect(call.method, 'presentChannel');
|
||||
channelPresentations++;
|
||||
if (channelPresentations == 1) {
|
||||
return {
|
||||
'action': 'canvas',
|
||||
'name': 'Draft name',
|
||||
'description': 'Draft description',
|
||||
};
|
||||
}
|
||||
expect(args['canvasContent'], 'New canvas');
|
||||
expect(args['canvasLoaded'], true);
|
||||
expect(args['initialValue'], 'Draft name');
|
||||
expect(args['description'], 'Draft description');
|
||||
expect(args['originalName'], 'general');
|
||||
expect(args['originalDescription'], '');
|
||||
return {
|
||||
'action': 'save',
|
||||
'name': 'Draft name',
|
||||
'description': 'Draft description',
|
||||
};
|
||||
});
|
||||
addTearDown(
|
||||
() => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(bridge, null),
|
||||
);
|
||||
await tester.pumpWidget(
|
||||
_modalApp(
|
||||
channel: _channel(),
|
||||
loadMembers: () async => [
|
||||
ChannelMember(
|
||||
pubkey: _currentPubkey,
|
||||
role: 'owner',
|
||||
joinedAt: DateTime(2025),
|
||||
),
|
||||
],
|
||||
createChannelActions: (ref) => _FakeChannelActions(
|
||||
ref,
|
||||
onUpdateChannel: (_, name, description) async {
|
||||
expect(name, 'Draft name');
|
||||
expect(description, 'Draft description');
|
||||
savedDetails = true;
|
||||
},
|
||||
onSetCanvas: (content) async {
|
||||
expect(content, 'New canvas');
|
||||
savedCanvas = true;
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Open actions'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Manage channel'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(channelPresentations, 2);
|
||||
expect(savedDetails, isTrue);
|
||||
expect(savedCanvas, isTrue);
|
||||
expect(find.byType(ManageChannelSheet), findsNothing);
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'canvas preview opens its content and removal retries without losing drafts',
|
||||
(tester) async {
|
||||
var removals = 0;
|
||||
await tester.pumpWidget(
|
||||
_modalApp(
|
||||
channel: _channel(),
|
||||
canvasContent: 'Shared team notes',
|
||||
loadMembers: () async => [
|
||||
ChannelMember(
|
||||
pubkey: _currentPubkey,
|
||||
role: 'owner',
|
||||
joinedAt: DateTime(2025),
|
||||
),
|
||||
],
|
||||
createChannelActions: (ref) => _FakeChannelActions(
|
||||
ref,
|
||||
onSetCanvas: (content) async {
|
||||
expect(content, '');
|
||||
if (++removals == 1) throw Exception('offline');
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Open actions'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Manage channel'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Shared team notes'), findsOneWidget);
|
||||
expect(find.text('Add Canvas'), findsNothing);
|
||||
expect(find.text('Edit canvas'), findsNothing);
|
||||
await tester.enterText(
|
||||
find.byKey(const ValueKey('manage-channel-name')),
|
||||
'Draft name',
|
||||
);
|
||||
await tester.tap(find.byKey(const ValueKey('manage-channel-canvas')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
tester
|
||||
.widget<TextField>(find.byKey(const ValueKey('text-editor-input')))
|
||||
.controller!
|
||||
.text,
|
||||
'Shared team notes',
|
||||
);
|
||||
Navigator.of(
|
||||
tester.element(find.byKey(const ValueKey('text-editor-input'))),
|
||||
).pop();
|
||||
await tester.pumpAndSettle();
|
||||
for (var attempt = 0; attempt < 2; attempt++) {
|
||||
await tester.ensureVisible(find.text('Remove Canvas'));
|
||||
await tester.tap(find.text('Remove Canvas'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(removals, attempt);
|
||||
await tester.tap(find.text('Remove'));
|
||||
await tester.pumpAndSettle();
|
||||
if (attempt == 0) {
|
||||
expect(find.text('Shared team notes'), findsOneWidget);
|
||||
expect(
|
||||
find.text("Couldn't remove the canvas. Try again."),
|
||||
findsOneWidget,
|
||||
);
|
||||
}
|
||||
}
|
||||
expect(find.text('Add Canvas'), findsOneWidget);
|
||||
expect(find.text('Remove Canvas'), findsNothing);
|
||||
expect(
|
||||
tester
|
||||
.widget<TextField>(
|
||||
find.byKey(const ValueKey('manage-channel-name')),
|
||||
)
|
||||
.controller!
|
||||
.text,
|
||||
'Draft name',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'native removal retains canvas on failure and clears after retry',
|
||||
(tester) async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
||||
const bridge = MethodChannel('buzz/profile_text_editor');
|
||||
var presentations = 0;
|
||||
var attempts = 0;
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(bridge, (call) async {
|
||||
final args = Map<Object?, Object?>.from(call.arguments as Map);
|
||||
expect(call.method, 'presentChannel');
|
||||
expect(args['canvasLoaded'], true);
|
||||
expect(
|
||||
args['canvasContent'],
|
||||
presentations < 2 ? 'Existing canvas' : '',
|
||||
);
|
||||
if (presentations > 0) {
|
||||
expect(args['initialValue'], 'Draft name');
|
||||
expect(args['description'], 'Draft description');
|
||||
}
|
||||
if (++presentations == 3) return null;
|
||||
return {
|
||||
'action': 'removeCanvas',
|
||||
'name': 'Draft name',
|
||||
'description': 'Draft description',
|
||||
};
|
||||
});
|
||||
addTearDown(
|
||||
() => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(bridge, null),
|
||||
);
|
||||
await tester.pumpWidget(
|
||||
_modalApp(
|
||||
channel: _channel(),
|
||||
canvasContent: 'Existing canvas',
|
||||
loadMembers: () async => [],
|
||||
createChannelActions: (ref) => _FakeChannelActions(
|
||||
ref,
|
||||
onSetCanvas: (content) async {
|
||||
expect(content, '');
|
||||
if (++attempts == 1) throw Exception('offline');
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Open actions'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Manage channel'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(presentations, 3);
|
||||
expect(attempts, 2);
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('non-member cannot edit canvas from Manage channel', (
|
||||
tester,
|
||||
) async {
|
||||
@@ -428,7 +768,7 @@ void main() {
|
||||
await tester.tap(find.text('Manage channel'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final editCanvas = find.widgetWithText(FilledButton, 'Create canvas');
|
||||
final editCanvas = find.byKey(const ValueKey('manage-channel-canvas'));
|
||||
expect(editCanvas, findsOneWidget);
|
||||
expect(tester.widget<FilledButton>(editCanvas).onPressed, isNull);
|
||||
});
|
||||
@@ -466,15 +806,7 @@ void main() {
|
||||
expect(
|
||||
tester
|
||||
.widget<FilledButton>(
|
||||
find.byKey(const ValueKey('manage-channel-save-details')),
|
||||
)
|
||||
.onPressed,
|
||||
isNull,
|
||||
);
|
||||
expect(
|
||||
tester
|
||||
.widget<FilledButton>(
|
||||
find.widgetWithText(FilledButton, 'Create canvas'),
|
||||
find.byKey(const ValueKey('manage-channel-canvas')),
|
||||
)
|
||||
.onPressed,
|
||||
isNotNull,
|
||||
@@ -539,7 +871,9 @@ class _FakeChannelActions extends ChannelActions {
|
||||
)?
|
||||
onUpdateChannel;
|
||||
|
||||
_FakeChannelActions(Ref ref, {this.onUpdateChannel})
|
||||
final Future<void> Function(String)? onSetCanvas;
|
||||
|
||||
_FakeChannelActions(Ref ref, {this.onUpdateChannel, this.onSetCanvas})
|
||||
: super(
|
||||
ref: ref,
|
||||
session: ref.read(relaySessionProvider.notifier),
|
||||
@@ -559,6 +893,14 @@ class _FakeChannelActions extends ChannelActions {
|
||||
await onUpdateChannel?.call(channelId, name, description);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setCanvas({
|
||||
required String channelId,
|
||||
required String content,
|
||||
}) async {
|
||||
await onSetCanvas?.call(content);
|
||||
}
|
||||
|
||||
String? unarchivedChannelId;
|
||||
|
||||
@override
|
||||
|
||||
@@ -45,6 +45,7 @@ import 'package:buzz/features/channels/message_action_backdrop_state.dart';
|
||||
import 'package:buzz/features/channels/message_actions.dart';
|
||||
import 'package:buzz/features/channels/mobile_huddle_controller.dart';
|
||||
import 'package:buzz/features/channels/reaction_row.dart';
|
||||
import 'package:buzz/features/channels/message_mention_pill.dart';
|
||||
import 'package:buzz/features/channels/thread_detail_page.dart';
|
||||
import 'package:buzz/features/channels/thread_replies_provider.dart';
|
||||
import 'package:buzz/features/channels/timeline_message.dart';
|
||||
@@ -78,6 +79,7 @@ part 'thread_reply_refresh_cases.dart';
|
||||
part 'thread_title_capsule_cases.dart';
|
||||
part 'channel_detail_page_test/loading_review_tests.dart';
|
||||
part 'channel_detail_page_test/presence_tests.dart';
|
||||
part 'channel_detail_page_test/action_row_tests.dart';
|
||||
|
||||
const _channelId = '11111111-2222-4333-8444-555555555555';
|
||||
const _huddleChannelId = '8d764100-fd8f-44cf-9c98-6d8fbd739b8c';
|
||||
@@ -496,6 +498,7 @@ void main() {
|
||||
threadReplyRefreshTests();
|
||||
threadTitleCapsuleTests();
|
||||
presenceTests();
|
||||
actionRowTests();
|
||||
setUp(() async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
_testPrefs = await SharedPreferences.getInstance();
|
||||
@@ -3052,7 +3055,7 @@ void main() {
|
||||
|
||||
final sheet = find.byType(BottomSheet).last;
|
||||
expect(find.byType(BottomSheet), findsOneWidget);
|
||||
expect(tester.getSize(sheet).height, lessThanOrEqualTo(720));
|
||||
expect(tester.getSize(sheet).height, greaterThan(640));
|
||||
|
||||
final sheetTop = tester.getTopLeft(sheet).dy;
|
||||
await tester.dragFrom(
|
||||
@@ -3061,7 +3064,7 @@ void main() {
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Manage channel'), findsNothing);
|
||||
expect(find.text('Edit channel'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('Edit updates name and description without legacy fields', (
|
||||
@@ -3103,28 +3106,9 @@ void main() {
|
||||
expect(find.text('Leave channel'), findsNothing);
|
||||
expect(find.text('Topic'), findsNothing);
|
||||
expect(find.text('Purpose'), findsNothing);
|
||||
expect(find.text('Canvas'), findsOneWidget);
|
||||
|
||||
final nameField = tester.widget<TextField>(
|
||||
find.byKey(const ValueKey('manage-channel-name')),
|
||||
);
|
||||
final descriptionField = tester.widget<TextField>(
|
||||
find.byKey(const ValueKey('manage-channel-description')),
|
||||
);
|
||||
expect(nameField.decoration?.labelText, isNull);
|
||||
expect(nameField.decoration?.hintText, 'Channel name');
|
||||
expect(nameField.decoration?.border, InputBorder.none);
|
||||
expect(descriptionField.decoration?.labelText, isNull);
|
||||
expect(descriptionField.decoration?.hintText, 'Description');
|
||||
expect(descriptionField.decoration?.border, InputBorder.none);
|
||||
final nameOutline = tester.getRect(
|
||||
find.byKey(const ValueKey('manage-channel-name-outline')),
|
||||
);
|
||||
final descriptionOutline = tester.getRect(
|
||||
find.byKey(const ValueKey('manage-channel-description-outline')),
|
||||
);
|
||||
expect(descriptionOutline.top - nameOutline.bottom, Grid.xs);
|
||||
expect(find.text('Add Canvas'), findsOneWidget);
|
||||
|
||||
expect(find.text('Edit channel'), findsOneWidget);
|
||||
await tester.enterText(
|
||||
find.byKey(const ValueKey('manage-channel-name')),
|
||||
' #renamed ',
|
||||
@@ -3138,7 +3122,6 @@ void main() {
|
||||
find.byKey(const ValueKey('manage-channel-save-details')),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(updatedName, 'renamed');
|
||||
expect(updatedDescription, 'A new description');
|
||||
expect(find.text('renamed'), findsOneWidget);
|
||||
@@ -9433,11 +9416,7 @@ void main() {
|
||||
messages: [
|
||||
_systemMsg(
|
||||
id: 'sys-accessible',
|
||||
payload: {
|
||||
'type': 'topic_changed',
|
||||
'actor': 'alice',
|
||||
'topic': 'Release planning',
|
||||
},
|
||||
payload: {'type': 'member_left', 'actor': 'alice'},
|
||||
createdAt:
|
||||
DateTime(2026, 7, 28, 12, 34).millisecondsSinceEpoch ~/
|
||||
1000,
|
||||
@@ -9488,7 +9467,9 @@ void main() {
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Alice removed Bob from the channel'), findsOneWidget);
|
||||
expect(find.text('Alice'), findsOneWidget);
|
||||
expect(findRichText('removed '), findsOneWidget);
|
||||
expect(find.widgetWithText(MessageMentionPill, 'Bob'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('renders topic_changed system event', (tester) async {
|
||||
@@ -9514,7 +9495,7 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.text('Alice changed the topic to "Release planning"'),
|
||||
findRichText('changed the topic to "Release planning"'),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
@@ -9542,7 +9523,7 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.text('Alice changed the purpose to "Team standup notes"'),
|
||||
findRichText('changed the purpose to "Team standup notes"'),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
part of '../channel_detail_page_test.dart';
|
||||
|
||||
void actionRowTests() {
|
||||
const users = {
|
||||
'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'),
|
||||
'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'),
|
||||
};
|
||||
final cases = [
|
||||
(
|
||||
payload: <String, dynamic>{'type': 'member_removed', 'target': 'bob'},
|
||||
action: 'removed Bob from the channel',
|
||||
),
|
||||
(
|
||||
payload: <String, dynamic>{'type': 'member_removed'},
|
||||
action: 'removed Someone from the channel',
|
||||
),
|
||||
for (final field in ['topic', 'purpose']) ...[
|
||||
(
|
||||
payload: <String, dynamic>{
|
||||
'type': '${field}_changed',
|
||||
field: ' Release planning ',
|
||||
},
|
||||
action: 'changed the $field to "Release planning"',
|
||||
),
|
||||
for (final blank in [null, '', ' \n\t '])
|
||||
(
|
||||
payload: <String, dynamic>{'type': '${field}_changed', field: blank},
|
||||
action: 'cleared the $field',
|
||||
),
|
||||
],
|
||||
];
|
||||
|
||||
group('Channel action rows', () {
|
||||
for (var index = 0; index < cases.length; index++) {
|
||||
final scenario = cases[index];
|
||||
testWidgets('uses message layout for action $index', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_buildTestable(
|
||||
messages: [
|
||||
_textMsg(
|
||||
id: 'regular',
|
||||
pubkey: 'bob',
|
||||
content: 'Regular message',
|
||||
createdAt: 1000,
|
||||
),
|
||||
_systemMsg(
|
||||
id: 'action',
|
||||
payload: {...scenario.payload, 'actor': 'alice'},
|
||||
createdAt: 1010,
|
||||
),
|
||||
_reaction(id: 'reaction', targetId: 'action'),
|
||||
],
|
||||
users: users,
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final row = find.byKey(const ValueKey('system-message-row-action'));
|
||||
final avatars = find.descendant(
|
||||
of: row,
|
||||
matching: find.byType(CircleAvatar),
|
||||
);
|
||||
expect(avatars, findsOneWidget);
|
||||
expect(tester.getSize(avatars), const Size.square(messageAvatarSize));
|
||||
final author = find.byKey(
|
||||
const ValueKey('system-message-author-alice'),
|
||||
);
|
||||
expect(author, findsOneWidget);
|
||||
expect(
|
||||
find.byKey(const ValueKey('system-message-author-bob')),
|
||||
findsNothing,
|
||||
);
|
||||
expect(findRichText('Alice ${scenario.action}'), findsNothing);
|
||||
final body = findRichText(
|
||||
scenario.payload['target'] == 'bob' ? 'removed ' : scenario.action,
|
||||
);
|
||||
if (scenario.payload['target'] == 'bob') {
|
||||
expect(
|
||||
find.widgetWithText(MessageMentionPill, 'Bob'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('@'), findsOneWidget);
|
||||
}
|
||||
expect(body, findsOneWidget);
|
||||
final regularBody = tester.getRect(findRichText('Regular message'));
|
||||
final regularAuthor = tester.getRect(
|
||||
find.byKey(const ValueKey('message-author-regular')),
|
||||
);
|
||||
final actionBody = tester.getRect(body);
|
||||
final actionAuthor = tester.getRect(author);
|
||||
expect(actionBody.left, regularBody.left);
|
||||
expect(actionAuthor.left, regularAuthor.left);
|
||||
expect(
|
||||
actionBody.top - actionAuthor.bottom,
|
||||
closeTo(regularBody.top - regularAuthor.bottom, 0.01),
|
||||
);
|
||||
final timestamp = find.byKey(
|
||||
const ValueKey('system-message-timestamp-alice'),
|
||||
);
|
||||
expect(tester.widget<Text>(timestamp).data, formatMessageTime(1010));
|
||||
expect(tester.getRect(timestamp).top, lessThan(actionBody.top));
|
||||
final reactions = find.descendant(
|
||||
of: row,
|
||||
matching: find.byType(ReactionRow),
|
||||
);
|
||||
expect(reactions, findsOneWidget);
|
||||
expect(tester.getTopLeft(reactions).dx, actionBody.left);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('keeps missing-actor fallback for action $index', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
_buildTestable(
|
||||
messages: [_systemMsg(id: 'fallback', payload: scenario.payload)],
|
||||
users: users,
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Someone ${scenario.action}'), findsOneWidget);
|
||||
expect(find.byIcon(BuzzIcons.arrowLeftRight), findsOneWidget);
|
||||
expect(
|
||||
find.byKey(const ValueKey('system-message-timestamp-fallback')),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
testWidgets('removal keeps both profiles and reaction actions accessible', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
_buildTestable(
|
||||
messages: [
|
||||
_systemMsg(
|
||||
id: 'removal',
|
||||
payload: {
|
||||
'type': 'member_removed',
|
||||
'actor': 'alice',
|
||||
'target': 'bob',
|
||||
},
|
||||
),
|
||||
],
|
||||
users: users,
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byType(CircleAvatar));
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
tester.widget<UserProfileSheet>(find.byType(UserProfileSheet)).pubkey,
|
||||
'alice',
|
||||
);
|
||||
await tester.tap(find.byTooltip('Close sheet'));
|
||||
await tester.pumpAndSettle();
|
||||
final targetSemantics = find.semantics.byLabel('Bob').evaluate();
|
||||
expect(targetSemantics, hasLength(1));
|
||||
expect(
|
||||
targetSemantics.single.getSemanticsData().hasAction(
|
||||
SemanticsAction.tap,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
await tester.tap(find.widgetWithText(MessageMentionPill, 'Bob'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
tester.widget<UserProfileSheet>(find.byType(UserProfileSheet)).pubkey,
|
||||
'bob',
|
||||
);
|
||||
await tester.tap(find.byTooltip('Close sheet'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.longPress(
|
||||
find.byKey(const ValueKey('system-message-row-removal')),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const ValueKey('quick-reaction-more')), findsOneWidget);
|
||||
Navigator.of(
|
||||
tester.element(find.byKey(const ValueKey('reaction-popover-tray'))),
|
||||
).pop();
|
||||
await tester.pumpAndSettle();
|
||||
});
|
||||
|
||||
for (final source in ['directory', 'profile', 'channel role']) {
|
||||
testWidgets('removed agent uses chat mention pill from $source', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
_buildTestable(
|
||||
messages: [
|
||||
_systemMsg(
|
||||
id: 'agent-removal',
|
||||
payload: {
|
||||
'type': 'member_removed',
|
||||
'actor': 'alice',
|
||||
'target': 'bob',
|
||||
},
|
||||
),
|
||||
],
|
||||
users: {
|
||||
...users,
|
||||
'bob': UserProfile(
|
||||
pubkey: 'bob',
|
||||
displayName: 'Helper Bot',
|
||||
ownerPubkey: source == 'profile' ? 'alice' : null,
|
||||
),
|
||||
},
|
||||
knownAgentPubkeys: source == 'directory' ? {'bob'} : {},
|
||||
loadChannelBotPubkeys: () async =>
|
||||
source == 'channel role' ? {'bob'} : {},
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
final pill = find.widgetWithText(MessageMentionPill, 'Helper Bot');
|
||||
expect(pill, findsOneWidget);
|
||||
expect(tester.widget<MessageMentionPill>(pill).isAgent, isTrue);
|
||||
expect(
|
||||
find.descendant(of: pill, matching: find.byIcon(BuzzIcons.bot)),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.descendant(of: pill, matching: find.text('@')),
|
||||
findsNothing,
|
||||
);
|
||||
expect(
|
||||
tester.widget<Text>(find.text('Helper Bot')).style?.decoration,
|
||||
isNot(TextDecoration.underline),
|
||||
);
|
||||
final target = find.semantics.byLabel('Helper Bot').evaluate();
|
||||
expect(target, hasLength(1));
|
||||
expect(
|
||||
target.single.getSemanticsData().hasAction(SemanticsAction.tap),
|
||||
isTrue,
|
||||
);
|
||||
await tester.tap(pill);
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
tester.widget<UserProfileSheet>(find.byType(UserProfileSheet)).pubkey,
|
||||
'bob',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
testWidgets(
|
||||
'agent headers, action targets, and profiles keep distinct identities',
|
||||
(tester) async {
|
||||
final first = 'a' * 64, second = 'b' * 64;
|
||||
final agents = {first, second};
|
||||
final profiles = {
|
||||
for (final key in agents)
|
||||
key: UserProfile(pubkey: key, displayName: 'Scout'),
|
||||
};
|
||||
final names = IdentityNameSources(
|
||||
profiles: profiles,
|
||||
agentPubkeys: agents,
|
||||
).scope(agents);
|
||||
await tester.pumpWidget(
|
||||
_buildTestable(
|
||||
messages: [
|
||||
_textMsg(id: 'agent-message', pubkey: first, content: 'hello'),
|
||||
_systemMsg(
|
||||
id: 'agent-removal',
|
||||
payload: {
|
||||
'type': 'member_removed',
|
||||
'actor': first,
|
||||
'target': second,
|
||||
},
|
||||
),
|
||||
],
|
||||
users: profiles,
|
||||
knownAgentPubkeys: agents,
|
||||
members: [
|
||||
for (final key in agents)
|
||||
ChannelMember(
|
||||
pubkey: key,
|
||||
role: 'bot',
|
||||
joinedAt: DateTime(2025),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
final header = find.byKey(
|
||||
const ValueKey('message-author-agent-message'),
|
||||
);
|
||||
expect(tester.widget<Text>(header).data, names.labelFor(first));
|
||||
expect(
|
||||
tester
|
||||
.widget<Text>(
|
||||
find.byKey(ValueKey('system-message-author-$first')),
|
||||
)
|
||||
.data,
|
||||
names.labelFor(first),
|
||||
);
|
||||
final pill = find.widgetWithText(
|
||||
MessageMentionPill,
|
||||
names.labelFor(second),
|
||||
);
|
||||
expect(pill, findsOneWidget);
|
||||
expect(names.labelFor(first), isNot(names.labelFor(second)));
|
||||
expect(find.text('Scout'), findsNothing);
|
||||
for (final entry in [(header, first), (pill, second)]) {
|
||||
await tester.tap(entry.$1);
|
||||
await tester.pumpAndSettle();
|
||||
final sheet = find.byType(UserProfileSheet);
|
||||
expect(tester.widget<UserProfileSheet>(sheet).pubkey, entry.$2);
|
||||
expect(
|
||||
find.descendant(
|
||||
of: sheet,
|
||||
matching: find.text(names.resolve(entry.$2)!.name),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
await tester.tap(find.byTooltip('Close sheet'));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
expect(tester.takeException(), isNull);
|
||||
},
|
||||
);
|
||||
|
||||
for (final scenario in [cases.first, cases[2]]) {
|
||||
testWidgets('wraps ${scenario.payload['type']} at large text sizes', (
|
||||
tester,
|
||||
) async {
|
||||
tester.view.physicalSize = const Size(320, 800);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
await tester.pumpWidget(
|
||||
_buildTestable(
|
||||
messages: [
|
||||
_systemMsg(
|
||||
id: 'large',
|
||||
payload: {...scenario.payload, 'actor': 'alice'},
|
||||
),
|
||||
],
|
||||
users: users,
|
||||
textScaler: const TextScaler.linear(3),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
findRichText(
|
||||
scenario.payload['target'] == 'bob' ? 'removed ' : scenario.action,
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.byKey(const ValueKey('system-message-timestamp-alice')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import 'package:buzz/features/channels/channel_member_profile_actions.dart';
|
||||
import 'package:buzz/features/channels/channels_provider.dart';
|
||||
import 'package:buzz/shared/theme/theme.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
@@ -113,6 +115,108 @@ Future<ProviderContainer> _pump(
|
||||
}
|
||||
|
||||
void main() {
|
||||
for (final platform in [TargetPlatform.iOS, TargetPlatform.android]) {
|
||||
testWidgets(
|
||||
'same-name agents have distinct ${platform.name} removal confirmations',
|
||||
(tester) async {
|
||||
debugDefaultTargetPlatformOverride = platform;
|
||||
const bridge = MethodChannel('buzz/confirmation_dialog');
|
||||
final messages = <String>[];
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(bridge, (call) async {
|
||||
messages.add((call.arguments as Map)['message'] as String);
|
||||
return true;
|
||||
});
|
||||
final keys = ['a' * 64, 'b' * 64];
|
||||
final actions = _Actions();
|
||||
try {
|
||||
for (final key in keys) {
|
||||
await _pump(
|
||||
tester,
|
||||
target: key,
|
||||
actions: actions,
|
||||
inSheet: true,
|
||||
load: () async => [
|
||||
_member('self', 'owner'),
|
||||
for (final agent in keys)
|
||||
ChannelMember(
|
||||
pubkey: agent,
|
||||
displayName: 'Scout',
|
||||
role: 'bot',
|
||||
joinedAt: DateTime(2025),
|
||||
),
|
||||
],
|
||||
);
|
||||
await tester.tap(find.text('Remove from channel'));
|
||||
await tester.pumpAndSettle();
|
||||
if (platform == TargetPlatform.android) {
|
||||
final dialog = tester.widget<AlertDialog>(
|
||||
find.byWidgetPredicate((widget) => widget is AlertDialog),
|
||||
);
|
||||
messages.add((dialog.content! as Text).data!);
|
||||
await tester.tap(find.text('Remove'));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
}
|
||||
expect(messages, hasLength(2));
|
||||
expect(messages.toSet(), hasLength(2));
|
||||
for (final message in messages) {
|
||||
expect(message, startsWith('Remove Scout · '));
|
||||
}
|
||||
expect(actions.calls, [for (final key in keys) 'test:$key:remove']);
|
||||
} finally {
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(bridge, null);
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
for (final confirmed in [false, true]) {
|
||||
testWidgets('iOS removal uses native confirmation: $confirmed', (
|
||||
tester,
|
||||
) async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
const bridge = MethodChannel('buzz/confirmation_dialog');
|
||||
final response = Completer<bool>();
|
||||
final calls = <MethodCall>[];
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(bridge, (call) {
|
||||
calls.add(call);
|
||||
return response.future;
|
||||
});
|
||||
try {
|
||||
final actions = _Actions();
|
||||
await _pump(tester, actions: actions, inSheet: true);
|
||||
await tester.tap(find.text('Remove from channel'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(calls, hasLength(1));
|
||||
expect(calls.single.method, 'present');
|
||||
expect(
|
||||
calls.single.arguments,
|
||||
containsPair('title', 'Remove from channel?'),
|
||||
);
|
||||
expect(calls.single.arguments, containsPair('confirmLabel', 'Remove'));
|
||||
expect(calls.single.arguments, containsPair('cancelLabel', 'Cancel'));
|
||||
expect(find.byType(AlertDialog), findsNothing);
|
||||
expect(actions.calls, isEmpty);
|
||||
response.complete(confirmed);
|
||||
await tester.pumpAndSettle();
|
||||
expect(actions.calls, confirmed ? ['test:alice:remove'] : isEmpty);
|
||||
expect(
|
||||
find.byType(ChannelMemberProfileActions),
|
||||
confirmed ? findsNothing : findsOneWidget,
|
||||
);
|
||||
} finally {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(bridge, null);
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
testWidgets('successful removal closes the member sheet', (tester) async {
|
||||
final actions = _Actions();
|
||||
await _pump(tester, actions: actions, inSheet: true);
|
||||
|
||||
370
mobile/test/features/channels/ios_add_members_sheet_test.dart
Normal file
370
mobile/test/features/channels/ios_add_members_sheet_test.dart
Normal file
@@ -0,0 +1,370 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:buzz/features/channels/add_members_sheet.dart';
|
||||
import 'package:buzz/features/channels/channel_management_provider.dart';
|
||||
import 'package:buzz/shared/profile/user_cache_provider.dart';
|
||||
import 'package:buzz/shared/profile/user_profile.dart';
|
||||
import 'package:buzz/shared/theme/theme.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';
|
||||
|
||||
final _alice = 'a' * 64;
|
||||
final _bob = 'b' * 64;
|
||||
final _existing = 'e' * 64;
|
||||
final _avatar =
|
||||
'data:image/svg+xml,${Uri.encodeComponent('<svg xmlns="http://www.w3.org/2000/svg" width="72" height="72"><rect width="72" height="72" fill="#0000ff"/></svg>')}';
|
||||
const _bridge = MethodChannel('buzz/add_members_sheet');
|
||||
|
||||
class _Cache extends UserCacheNotifier {
|
||||
@override
|
||||
Map<String, UserProfile> build() => const {};
|
||||
@override
|
||||
Future<bool> preload(List<String> pubkeys) async => true;
|
||||
}
|
||||
|
||||
class _Actions extends Fake implements ChannelActions {
|
||||
final calls = <List<String>>[];
|
||||
bool failBob = false;
|
||||
@override
|
||||
Future<void> addMembers({
|
||||
required String channelId,
|
||||
required List<String> pubkeys,
|
||||
String role = 'member',
|
||||
}) async {
|
||||
calls.add(pubkeys);
|
||||
if (failBob) throw AddMembersException({_bob: 'Try again'});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pump(
|
||||
WidgetTester tester,
|
||||
_Actions actions, {
|
||||
List<DirectoryUser>? directory,
|
||||
}) async {
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
userCacheProvider.overrideWith(_Cache.new),
|
||||
channelActionsProvider.overrideWithValue(actions),
|
||||
channelMembersProvider('channel').overrideWith((ref) async => []),
|
||||
relayDirectoryUsersProvider.overrideWith(
|
||||
(ref) async =>
|
||||
directory ??
|
||||
[
|
||||
DirectoryUser(pubkey: _alice, displayName: 'Alice'),
|
||||
DirectoryUser(
|
||||
pubkey: _bob,
|
||||
displayName: 'Bob',
|
||||
isAgent: true,
|
||||
avatarUrl: _avatar,
|
||||
),
|
||||
DirectoryUser(pubkey: _existing, displayName: 'Existing'),
|
||||
],
|
||||
),
|
||||
relayDirectorySearchProvider('bob').overrideWith(
|
||||
(ref) async => [
|
||||
DirectoryUser(
|
||||
pubkey: _bob,
|
||||
displayName: 'Bob',
|
||||
isAgent: true,
|
||||
avatarUrl: _avatar,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
child: MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
home: Scaffold(
|
||||
body: Builder(
|
||||
builder: (context) => TextButton(
|
||||
onPressed: () => showAddChannelMembersSheet(
|
||||
context: context,
|
||||
channelId: 'channel',
|
||||
existingPubkeys: {_existing},
|
||||
),
|
||||
child: const Text('Open picker'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.tap(find.text('Open picker'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
Future<void> _event(
|
||||
WidgetTester tester,
|
||||
String session,
|
||||
String method, [
|
||||
Map<String, Object?> values = const {},
|
||||
]) async {
|
||||
final response = Completer<void>();
|
||||
await tester.binding.defaultBinaryMessenger.handlePlatformMessage(
|
||||
_bridge.name,
|
||||
const StandardMethodCodec().encodeMethodCall(
|
||||
MethodCall(method, {'session': session, ...values}),
|
||||
),
|
||||
(_) => response.complete(),
|
||||
);
|
||||
await response.future;
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
void main() {
|
||||
for (final platform in [TargetPlatform.iOS, TargetPlatform.android]) {
|
||||
testWidgets(
|
||||
'same-name agents remain distinct in ${platform.name} member choices',
|
||||
(tester) async {
|
||||
debugDefaultTargetPlatformOverride = platform;
|
||||
final states = <Map<Object?, Object?>>[];
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(_bridge, (call) async {
|
||||
if (call.method != 'dismiss') {
|
||||
states.add(Map<Object?, Object?>.from(call.arguments as Map));
|
||||
}
|
||||
return null;
|
||||
});
|
||||
try {
|
||||
final actions = _Actions();
|
||||
await _pump(
|
||||
tester,
|
||||
actions,
|
||||
directory: [
|
||||
for (final key in [_alice, _bob])
|
||||
DirectoryUser(pubkey: key, displayName: 'Scout', isAgent: true),
|
||||
],
|
||||
);
|
||||
if (platform == TargetPlatform.iOS) {
|
||||
final rows = states.last['users'] as List;
|
||||
expect(rows.map((row) => row['name']).toSet(), hasLength(2));
|
||||
for (final row in rows) {
|
||||
expect(row['name'], startsWith('Scout · '));
|
||||
}
|
||||
final session = states.last['session'] as String;
|
||||
await _event(tester, session, 'toggle', {'pubkey': _bob});
|
||||
final selected = (states.last['selected'] as List).single as Map;
|
||||
expect(selected['pubkey'], _bob);
|
||||
expect(selected['name'], (rows.last as Map)['name']);
|
||||
await _event(tester, session, 'submit');
|
||||
} else {
|
||||
final labels = tester
|
||||
.widgetList<Text>(find.byType(Text))
|
||||
.map((text) => text.data)
|
||||
.whereType<String>()
|
||||
.where((label) => label.startsWith('Scout · '))
|
||||
.toList();
|
||||
expect(labels.toSet(), hasLength(2));
|
||||
final semantics = tester.ensureSemantics();
|
||||
expect(
|
||||
find.bySemanticsLabel(RegExp(RegExp.escape(labels.last))),
|
||||
findsWidgets,
|
||||
);
|
||||
semantics.dispose();
|
||||
await tester.tap(find.text(labels.last));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Add member'));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
expect(actions.calls, [
|
||||
[_bob],
|
||||
]);
|
||||
} finally {
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(_bridge, null);
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets(
|
||||
'iOS native search retains selections and retries only failed members',
|
||||
(tester) async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
final states = <Map<Object?, Object?>>[];
|
||||
final methods = <String>[];
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(_bridge, (call) async {
|
||||
methods.add(call.method);
|
||||
if (call.method != 'dismiss') {
|
||||
states.add(Map<Object?, Object?>.from(call.arguments as Map));
|
||||
}
|
||||
return null;
|
||||
});
|
||||
try {
|
||||
final actions = _Actions()..failBob = true;
|
||||
await _pump(tester, actions);
|
||||
expect(methods.first, 'present');
|
||||
expect(find.byType(TextField), findsNothing);
|
||||
expect((states.last['users'] as List).map((row) => row['pubkey']), [
|
||||
_alice,
|
||||
_bob,
|
||||
]);
|
||||
final session = states.last['session'] as String;
|
||||
await _event(tester, 'stale-session', 'toggle', {'pubkey': _alice});
|
||||
expect(states.last['selected'], isEmpty);
|
||||
await _event(tester, session, 'toggle', {'pubkey': _alice});
|
||||
await _event(tester, session, 'toggle', {'pubkey': _bob});
|
||||
expect(states.last['selected'] as List, hasLength(2));
|
||||
await _event(tester, session, 'query', {'value': 'bob'});
|
||||
expect(states.last['loading'], isTrue);
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
await tester.pumpAndSettle();
|
||||
expect(states.last['loading'], isFalse);
|
||||
expect((states.last['users'] as List).map((row) => row['pubkey']), [
|
||||
_bob,
|
||||
]);
|
||||
expect(states.last['selected'] as List, hasLength(2));
|
||||
await _event(tester, session, 'submit');
|
||||
expect(actions.calls, [
|
||||
[_alice, _bob],
|
||||
]);
|
||||
expect((states.last['selected'] as List).map((row) => row['pubkey']), [
|
||||
_bob,
|
||||
]);
|
||||
expect(states.last['error'], isNotNull);
|
||||
await _event(tester, session, 'query', {'value': ''});
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
await tester.pumpAndSettle();
|
||||
expect((states.last['users'] as List).map((row) => row['pubkey']), [
|
||||
_bob,
|
||||
]);
|
||||
actions.failBob = false;
|
||||
await _event(tester, session, 'submit');
|
||||
expect(actions.calls, [
|
||||
[_alice, _bob],
|
||||
[_bob],
|
||||
]);
|
||||
expect(find.text('Open picker'), findsOneWidget);
|
||||
expect(methods.last, 'dismiss');
|
||||
} finally {
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(_bridge, null);
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'native picker renders member artwork and rejects stale avatar requests',
|
||||
(tester) async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
final states = <Map<Object?, Object?>>[];
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(_bridge, (call) async {
|
||||
if (call.method != 'dismiss') {
|
||||
states.add(Map<Object?, Object?>.from(call.arguments as Map));
|
||||
}
|
||||
return null;
|
||||
});
|
||||
try {
|
||||
await _pump(tester, _Actions());
|
||||
final session = states.last['session'] as String;
|
||||
final row = (states.last['users'] as List).last as Map;
|
||||
Future<Object?> request(String key, {String? requestSession}) async {
|
||||
final response = Completer<Object?>();
|
||||
await tester.binding.defaultBinaryMessenger.handlePlatformMessage(
|
||||
_bridge.name,
|
||||
const StandardMethodCodec().encodeMethodCall(
|
||||
MethodCall('avatar', {
|
||||
'session': requestSession ?? session,
|
||||
'pubkey': _bob,
|
||||
'avatarKey': key,
|
||||
}),
|
||||
),
|
||||
(data) => response.complete(
|
||||
const StandardMethodCodec().decodeEnvelope(data!),
|
||||
),
|
||||
);
|
||||
return response.future;
|
||||
}
|
||||
|
||||
final key = row['avatarKey'] as String;
|
||||
await tester.runAsync(() async {
|
||||
expect(await request('outdated-avatar'), isNull);
|
||||
expect(await request(key, requestSession: 'old-session'), isNull);
|
||||
final bytes = await request(key) as Uint8List;
|
||||
final codec = await ui.instantiateImageCodec(bytes);
|
||||
final frame = await codec.getNextFrame();
|
||||
final pixels = (await frame.image.toByteData())!;
|
||||
final center = (36 * frame.image.width + 36) * 4;
|
||||
expect(
|
||||
[for (var i = 0; i < 4; i++) pixels.getUint8(center + i)],
|
||||
[0, 0, 255, 255],
|
||||
);
|
||||
frame.image.dispose();
|
||||
codec.dispose();
|
||||
expect(await request(key), orderedEquals(bytes));
|
||||
});
|
||||
await _event(tester, session, 'toggle', {'pubkey': _bob});
|
||||
expect(
|
||||
((states.last['selected'] as List).single as Map)['avatarKey'],
|
||||
key,
|
||||
);
|
||||
await _event(tester, session, 'query', {'value': 'bob'});
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
((states.last['users'] as List).single as Map)['avatarKey'],
|
||||
key,
|
||||
);
|
||||
await _event(tester, session, 'closed');
|
||||
} finally {
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(_bridge, null);
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('iOS dismiss sends no member writes', (tester) async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
String? session;
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(_bridge, (call) async {
|
||||
session = (call.arguments as Map)['session'] as String;
|
||||
return null;
|
||||
});
|
||||
try {
|
||||
final actions = _Actions();
|
||||
await _pump(tester, actions);
|
||||
await _event(tester, session!, 'toggle', {'pubkey': _alice});
|
||||
await _event(tester, session!, 'closed');
|
||||
expect(actions.calls, isEmpty);
|
||||
expect(find.byType(AddChannelMembersSheet), findsNothing);
|
||||
} finally {
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(_bridge, null);
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('Android keeps the existing sheet and search field', (
|
||||
tester,
|
||||
) async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
||||
try {
|
||||
await _pump(tester, _Actions());
|
||||
expect(find.text('Add members'), findsOneWidget);
|
||||
expect(
|
||||
find.byKey(const ValueKey('add-channel-members-search')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('Alice'), findsOneWidget);
|
||||
} finally {
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -88,9 +88,11 @@ void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
late _HeldSearchSession session;
|
||||
late ProviderContainer container;
|
||||
var memberChoices = <ChannelMember>[];
|
||||
|
||||
setUp(() async {
|
||||
session = _HeldSearchSession();
|
||||
memberChoices = [];
|
||||
container = ProviderContainer(
|
||||
retry: (_, _) => null,
|
||||
overrides: [
|
||||
@@ -100,7 +102,7 @@ void main() {
|
||||
channelsProvider.overrideWith(_Channels.new),
|
||||
channelMembersProvider(
|
||||
_channelId,
|
||||
).overrideWith((ref) async => const []),
|
||||
).overrideWith((ref) async => memberChoices),
|
||||
agentDirectoryProvider.overrideWith((ref) async => const []),
|
||||
agentOwnersProvider.overrideWith((ref) async => const {}),
|
||||
],
|
||||
@@ -137,6 +139,27 @@ void main() {
|
||||
candidate.pubkey,
|
||||
];
|
||||
|
||||
test('production mention chooser distinguishes same-name agents', () async {
|
||||
memberChoices = [
|
||||
for (final key in [_alice, _bob])
|
||||
ChannelMember(
|
||||
pubkey: key,
|
||||
displayName: 'Albert',
|
||||
role: 'bot',
|
||||
joinedAt: DateTime(2026),
|
||||
),
|
||||
];
|
||||
container.invalidate(channelMembersProvider(_channelId));
|
||||
await container.read(channelMembersProvider(_channelId).future);
|
||||
await container.pump();
|
||||
final choices = container.read(mentionCandidatesProvider(_args));
|
||||
expect(choices.map((choice) => choice.pubkey).toSet(), {_alice, _bob});
|
||||
expect(choices.map((choice) => choice.pickerLabel).toSet(), hasLength(2));
|
||||
for (final choice in choices) {
|
||||
expect(choice.pickerLabel, startsWith('Albert · '));
|
||||
}
|
||||
});
|
||||
|
||||
test('a late search result from community A is not shown in B', () async {
|
||||
await passTypingPause();
|
||||
expect(session.searches, hasLength(1));
|
||||
|
||||
269
mobile/test/features/settings/settings_overlay_test.dart
Normal file
269
mobile/test/features/settings/settings_overlay_test.dart
Normal file
@@ -0,0 +1,269 @@
|
||||
import 'dart:io';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:buzz/features/home/home_page.dart';
|
||||
import 'package:buzz/features/profile/profile_avatar.dart';
|
||||
import 'package:buzz/features/profile/profile_provider.dart';
|
||||
import 'package:buzz/features/profile/settings_profile_header.dart';
|
||||
import 'package:buzz/features/profile/user_status_provider.dart';
|
||||
import 'package:buzz/features/settings/settings_page.dart';
|
||||
import 'package:buzz/shared/custom_emoji/custom_emoji_provider.dart';
|
||||
import 'package:buzz/shared/relay/relay.dart';
|
||||
import 'package:buzz/shared/profile/user_profile.dart';
|
||||
import 'package:buzz/features/profile/user_status.dart';
|
||||
import 'package:buzz/shared/theme/theme.dart';
|
||||
import 'package:buzz/shared/widgets/frosted_app_bar.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:nostr/nostr.dart' as nostr;
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
void main() {
|
||||
for (final platform in [TargetPlatform.android, TargetPlatform.iOS]) {
|
||||
testWidgets('Settings overlays are visible above Home on ${platform.name}', (
|
||||
tester,
|
||||
) async {
|
||||
if (Platform.environment.containsKey('SETTINGS_SCREENSHOTS')) {
|
||||
await tester.runAsync(() async {
|
||||
for (final font in {
|
||||
'Inter': 'assets/fonts/InterVariable.ttf',
|
||||
'BuzzTabler': 'assets/fonts/TablerIcons.ttf',
|
||||
}.entries) {
|
||||
await (FontLoader(
|
||||
font.key,
|
||||
)..addFont(rootBundle.load(font.value))).load();
|
||||
}
|
||||
});
|
||||
}
|
||||
debugDefaultTargetPlatformOverride = platform;
|
||||
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
||||
tester.view.physicalSize = const Size(390, 844);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
tester.view.padding = FakeViewPadding(top: 47, bottom: 34);
|
||||
tester.view.viewPadding = FakeViewPadding(top: 47, bottom: 34);
|
||||
addTearDown(tester.view.reset);
|
||||
PackageInfo.setMockInitialValues(
|
||||
appName: 'Buzz',
|
||||
packageName: 'xyz.block.buzz',
|
||||
version: '0.16.0',
|
||||
buildNumber: '432',
|
||||
buildSignature: '',
|
||||
);
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final presence = _Presence();
|
||||
final nativeChannels = <MethodChannel>[];
|
||||
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
SystemChannels.platform_views,
|
||||
(call) async {
|
||||
if (call.method == 'create') {
|
||||
final args = call.arguments as Map;
|
||||
final channel = MethodChannel('${args['viewType']}/${args['id']}');
|
||||
nativeChannels.add(channel);
|
||||
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
channel,
|
||||
(_) async => null,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
);
|
||||
addTearDown(() {
|
||||
for (final channel in [
|
||||
SystemChannels.platform_views,
|
||||
...nativeChannels,
|
||||
]) {
|
||||
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
channel,
|
||||
null,
|
||||
);
|
||||
}
|
||||
});
|
||||
String? copied;
|
||||
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
SystemChannels.platform,
|
||||
(call) async {
|
||||
if (call.method == 'Clipboard.setData') {
|
||||
copied = (call.arguments as Map)['text'] as String;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
);
|
||||
addTearDown(() {
|
||||
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
SystemChannels.platform,
|
||||
null,
|
||||
);
|
||||
});
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
savedPrefsProvider.overrideWithValue(prefs),
|
||||
relayConfigProvider.overrideWith(_Config.new),
|
||||
appLifecycleProvider.overrideWith(_Lifecycle.new),
|
||||
profileProvider.overrideWith(_Profile.new),
|
||||
presenceProvider.overrideWith(() => presence),
|
||||
userStatusProvider.overrideWith(_Status.new),
|
||||
customEmojiListProvider.overrideWithValue(const []),
|
||||
],
|
||||
child: MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
builder: (_, child) => RepaintBoundary(
|
||||
key: const ValueKey('settings-test-capture'),
|
||||
child: child!,
|
||||
),
|
||||
home: HomePage(
|
||||
hasUnreadInbox: false,
|
||||
settingsPageBuilder: (_) => SettingsPage(
|
||||
profileHeader: const SettingsProfileHeader(),
|
||||
identityRecoveryPageBuilder: (_) => const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
if (platform == TargetPlatform.iOS) {
|
||||
// Invoke the same Flutter callback used by the native bar item.
|
||||
// Native actions are owned by FrostedAppBar and are independently tested.
|
||||
// Use that production callback without synthesizing a platform view tap.
|
||||
final appBar = tester
|
||||
.widgetList<FrostedAppBar>(find.byType(FrostedAppBar))
|
||||
.first;
|
||||
appBar.nativeActions!.single.onPressed!();
|
||||
} else {
|
||||
await tester.tap(find.byType(ProfileAvatar));
|
||||
}
|
||||
await tester.pumpAndSettle();
|
||||
if (platform == TargetPlatform.iOS) {
|
||||
expect(find.byType(HomePage), findsNothing);
|
||||
expect(find.byType(HomePage, skipOffstage: false), findsOneWidget);
|
||||
final menu = tester.widget<UiKitView>(
|
||||
find.byKey(const ValueKey('settings-presence-menu')),
|
||||
);
|
||||
expect(menu.viewType, 'buzz/presence_menu');
|
||||
expect(menu.creationParams, containsPair('presence', 'offline'));
|
||||
final nativeMenu = nativeChannels.singleWhere(
|
||||
(channel) => channel.name.startsWith('buzz/presence_menu/'),
|
||||
);
|
||||
await tester.binding.defaultBinaryMessenger.handlePlatformMessage(
|
||||
nativeMenu.name,
|
||||
const StandardMethodCodec().encodeMethodCall(
|
||||
const MethodCall('selected', 'online'),
|
||||
),
|
||||
(_) {},
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
} else {
|
||||
await tester.tap(find.byKey(const ValueKey('settings-presence-menu')));
|
||||
await tester.pumpAndSettle();
|
||||
final online = find.byKey(const ValueKey('settings-presence-online'));
|
||||
expect(online.hitTestable(), findsOneWidget);
|
||||
final fade = tester.widget<FadeTransition>(
|
||||
find.byKey(const ValueKey('activity-popover-fade')),
|
||||
);
|
||||
expect(fade.opacity.value, 1);
|
||||
final menu = tester.getRect(
|
||||
find.byKey(const ValueKey('settings-presence-popover')),
|
||||
);
|
||||
final anchor = tester.getRect(
|
||||
find.byKey(const ValueKey('settings-presence-target')),
|
||||
);
|
||||
expect(menu.center.dx, closeTo(anchor.center.dx, 0.1));
|
||||
expect(menu.top, closeTo(anchor.bottom + Grid.half, 0.1));
|
||||
expect(menu.bottom, lessThanOrEqualTo(810));
|
||||
await _capture(tester, '${platform.name}-availability');
|
||||
await tester.tapAt(const Offset(20, 700));
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
find.byKey(const ValueKey('settings-presence-popover')),
|
||||
findsNothing,
|
||||
);
|
||||
expect(presence.selected, isEmpty);
|
||||
await tester.tap(find.byKey(const ValueKey('settings-presence-menu')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(online);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
expect(presence.selected, ['online']);
|
||||
await tester.tap(find.text('Copy public key (npub)'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(copied, startsWith('npub1'));
|
||||
final snackbar = find.byType(SnackBar);
|
||||
expect(snackbar, findsOneWidget);
|
||||
final settings = find.byType(SettingsPage);
|
||||
expect(find.descendant(of: settings, matching: snackbar), findsOneWidget);
|
||||
final message = find.text('Public key (npub) copied');
|
||||
expect(message.hitTestable(), findsOneWidget);
|
||||
final toastSurface = find
|
||||
.ancestor(of: message, matching: find.byType(Material))
|
||||
.first;
|
||||
expect(tester.getRect(toastSurface).bottom, lessThanOrEqualTo(810));
|
||||
await _capture(tester, '${platform.name}-copied');
|
||||
Navigator.of(tester.element(settings)).pop();
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(SnackBar), findsNothing);
|
||||
expect(tester.takeException(), isNull);
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class _Presence extends PresenceNotifier {
|
||||
final selected = <String>[];
|
||||
@override
|
||||
Future<String> build() async => 'offline';
|
||||
@override
|
||||
Future<void> setPresence(String status) async {
|
||||
selected.add(status);
|
||||
state = AsyncData(status);
|
||||
}
|
||||
}
|
||||
|
||||
class _Profile extends ProfileNotifier {
|
||||
@override
|
||||
Future<UserProfile?> build() async =>
|
||||
UserProfile(pubkey: 'aabb', displayName: 'Test');
|
||||
}
|
||||
|
||||
class _Status extends UserStatusNotifier {
|
||||
@override
|
||||
Future<UserStatus?> build() async => null;
|
||||
}
|
||||
|
||||
class _Config extends RelayConfigNotifier {
|
||||
@override
|
||||
RelayConfig build() => RelayConfig(
|
||||
baseUrl: 'https://relay.test',
|
||||
nsec: nostr.Keys(
|
||||
'1111111111111111111111111111111111111111111111111111111111111111',
|
||||
).nsec,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _capture(WidgetTester tester, String name) async {
|
||||
final directory = Platform.environment['SETTINGS_SCREENSHOTS'];
|
||||
if (directory == null) return;
|
||||
final boundary = tester.renderObject<RenderRepaintBoundary>(
|
||||
find.byKey(const ValueKey('settings-test-capture')),
|
||||
);
|
||||
await tester.runAsync(() async {
|
||||
final image = await boundary.toImage(pixelRatio: 2);
|
||||
final bytes = await image.toByteData(format: ui.ImageByteFormat.png);
|
||||
await Directory(directory).create(recursive: true);
|
||||
await File(
|
||||
'$directory/$name.png',
|
||||
).writeAsBytes(bytes!.buffer.asUint8List());
|
||||
image.dispose();
|
||||
});
|
||||
}
|
||||
|
||||
class _Lifecycle extends AppLifecycleNotifier {
|
||||
@override
|
||||
AppLifecycleState build() => AppLifecycleState.resumed;
|
||||
}
|
||||
@@ -75,10 +75,20 @@ void main() {
|
||||
);
|
||||
expect(find.text('Invite to community'), findsNothing);
|
||||
await tester.pumpAndSettle();
|
||||
final version = find.text(
|
||||
buildNumber.isEmpty ? 'v0.16.0' : 'v0.16.0 ($buildNumber)',
|
||||
);
|
||||
await tester.scrollUntilVisible(version, 200);
|
||||
await tester.pumpAndSettle();
|
||||
expect(version.hitTestable(), findsOneWidget);
|
||||
expect(
|
||||
find.text(buildNumber.isEmpty ? 'v0.16.0' : 'v0.16.0 ($buildNumber)'),
|
||||
find.ancestor(of: version, matching: find.byType(ListView)),
|
||||
findsOneWidget,
|
||||
);
|
||||
final beforeScroll = tester.getTopLeft(version).dy;
|
||||
await tester.drag(find.byType(ListView), const Offset(0, 100));
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.getTopLeft(version).dy, greaterThan(beforeScroll));
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -35,6 +35,69 @@ IdentityNameSources _sources() => IdentityNameSources(
|
||||
);
|
||||
|
||||
void main() {
|
||||
for (final source in ['directory', 'channel role', 'profile owner']) {
|
||||
test('agent suffixes from $source distinguish all identity labels', () {
|
||||
final keys = [_mine, _wesAgent];
|
||||
final names = IdentityNameSources(
|
||||
agentPubkeys: source == 'directory' ? keys.toSet() : {},
|
||||
profiles: {
|
||||
for (final key in keys)
|
||||
key: UserProfile(
|
||||
pubkey: key,
|
||||
displayName: 'Scout',
|
||||
ownerPubkey: source == 'profile owner' ? _wes : null,
|
||||
),
|
||||
},
|
||||
).scope(keys, agentPubkeys: source == 'channel role' ? keys.toSet() : {});
|
||||
for (final key in keys) {
|
||||
expect(names.labelFor(key.toUpperCase()), names.resolve(key)!.name);
|
||||
final resolved = names.resolve(key)!;
|
||||
expect(resolved.qualifier, isNotNull);
|
||||
expect(names.labelFor(key), 'Scout · ${resolved.qualifier}');
|
||||
}
|
||||
expect(names.resolve(_mine)!.name, isNot(names.resolve(_wesAgent)!.name));
|
||||
});
|
||||
}
|
||||
|
||||
test('agent display keeps readable owners and literal name punctuation', () {
|
||||
final names = IdentityNameSources(
|
||||
viewer: _logan,
|
||||
profiles: {
|
||||
_wes: UserProfile(pubkey: _wes, displayName: 'Wes'),
|
||||
for (final key in [_mine, _wesAgent])
|
||||
key: UserProfile(
|
||||
pubkey: key,
|
||||
displayName: 'Scout · 1234',
|
||||
ownerPubkey: _wes,
|
||||
),
|
||||
},
|
||||
).scope([_mine, _wesAgent]);
|
||||
for (final key in [_mine, _wesAgent]) {
|
||||
expect(names.labelFor(key), startsWith('Wes’s Scout · 1234 · '));
|
||||
expect(names.resolve(key)!.qualifier, isNotNull);
|
||||
}
|
||||
expect(
|
||||
IdentityNameSources(
|
||||
agentDisplayNames: {_mine: 'Scout · 1234'},
|
||||
agentPubkeys: {_mine},
|
||||
).scope([_mine]).labelFor(_mine),
|
||||
'Scout · 1234',
|
||||
);
|
||||
});
|
||||
|
||||
test('human identity suffixes remain visible', () {
|
||||
final names = IdentityNameSources(
|
||||
profiles: {
|
||||
for (final key in [_mine, _wesAgent])
|
||||
key: UserProfile(pubkey: key, displayName: 'Scout'),
|
||||
},
|
||||
).scope([_mine, _wesAgent]);
|
||||
for (final key in [_mine, _wesAgent]) {
|
||||
expect(names.resolve(key)!.qualifier, isNotNull);
|
||||
expect(names.labelFor(key), names.resolve(key)!.name);
|
||||
}
|
||||
});
|
||||
|
||||
test('channel context: human, then mine, then owner-qualified others', () {
|
||||
// Wes is not a member: his profile is only an owner lookup fact.
|
||||
final names = _sources().scope([_logan, _human, _mine, _wesAgent]);
|
||||
@@ -114,8 +177,7 @@ void main() {
|
||||
];
|
||||
final names = mentionPickerNames(_sources(), candidates);
|
||||
final labeled = [
|
||||
for (final c in candidates)
|
||||
c.withContextLabel(names.resolve(c.pubkey)?.name),
|
||||
for (final c in candidates) c.withContextLabel(names.labelFor(c.pubkey)),
|
||||
];
|
||||
// A query that matches only the agent still shows its contextual label.
|
||||
final ranked = rankMentionCandidates(labeled, 'hon');
|
||||
@@ -154,6 +216,7 @@ void main() {
|
||||
expect(candidates.map((c) => c.pubkey), [scout, myScout]);
|
||||
final names = mentionPickerNames(sources, candidates);
|
||||
expect(names.labelFor(myScout), 'Scout');
|
||||
expect(names.labelFor(scout), isNot(names.labelFor(myScout)));
|
||||
expect(names.labelFor(scout), isNot('Scout'));
|
||||
// The owner is the viewer, whose profile is cached: nothing to load.
|
||||
expect(names.missingOwnerProfiles(), isEmpty);
|
||||
|
||||
18
scripts/test-mobile-member-picker.sh
Normal file
18
scripts/test-mobile-member-picker.sh
Normal file
@@ -0,0 +1,18 @@
|
||||
#!/bin/bash
|
||||
# Exercise the production UIKit member picker on a booted iOS simulator.
|
||||
set -euo pipefail
|
||||
repo_root=$(cd "$(dirname "$0")/.." && pwd)
|
||||
device=${1:?Pass a booted iOS simulator UDID}
|
||||
flutter_root=$(sed -n 's/^FLUTTER_ROOT=//p' "$repo_root/mobile/ios/Flutter/Generated.xcconfig")
|
||||
frameworks="$flutter_root/bin/cache/artifacts/engine/ios/Flutter.xcframework/ios-arm64_x86_64-simulator"
|
||||
fixture=$(mktemp -d)
|
||||
trap 'rm -r "$fixture"' EXIT
|
||||
xcrun --sdk iphonesimulator swiftc \
|
||||
-sdk "$(xcrun --sdk iphonesimulator --show-sdk-path)" \
|
||||
-target "$(uname -m)-apple-ios16.0-simulator" \
|
||||
-F "$frameworks" -framework Flutter \
|
||||
-Xlinker -rpath -Xlinker "$frameworks" \
|
||||
"$repo_root/mobile/ios/Runner/NativeAddMembersSheet.swift" \
|
||||
"$repo_root/mobile/ios/NativeControlTests/AddMembersIdentityTests.swift" \
|
||||
-o "$fixture/member-picker-tests"
|
||||
xcrun simctl spawn "$device" "$fixture/member-picker-tests"
|
||||
Reference in New Issue
Block a user