mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Polish mobile composer and messaging UI (#3918)
## Summary
- Refine the mobile composer with compact and expanded states, shared
footer fades, haptics, reliable keyboard dismissal, and full-width
camera and photo surfaces.
- Standardize popovers, filters, and section menus with consistent type,
strokes, radii, spacing, icons, and destructive styling.
- Align message presentation with desktop through consistent system
rows, typing and loading feedback, emoji placement, and predictable
photo viewing.
## Validation
- `just mobile-check`
- `just mobile-test` — 1,037 passed, 1 skipped
- Tested on Pixel 10 and a connected iPhone
## Snapshots
<table>
<tr>
<td align="center">Compact composer</td>
<td align="center">Attachment menu</td>
<td align="center">Recent photos</td>
</tr>
<tr>
<td><img
src="https://raw.githubusercontent.com/block/buzz/9732022cb13bb39ce797c4faaa714fe4c924955f/pr-3918--01-compact-composer.png"
width="260" /></td>
<td><img
src="https://raw.githubusercontent.com/block/buzz/9732022cb13bb39ce797c4faaa714fe4c924955f/pr-3918--02-attachment-menu.png"
width="260" /></td>
<td><img
src="https://raw.githubusercontent.com/block/buzz/9732022cb13bb39ce797c4faaa714fe4c924955f/pr-3918--03-photo-surface.png"
width="260" /></td>
</tr>
</table>
---------
Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
@@ -3,14 +3,6 @@ import PhotosUI
|
||||
import UIKit
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
enum EmbeddedPhotoPickerLayout {
|
||||
static func applyPreferredScale(_ zoomIn: () -> Void) {
|
||||
UIView.performWithoutAnimation {
|
||||
zoomIn()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class InlinePhotoPickerFactory: NSObject, FlutterPlatformViewFactory {
|
||||
private let messenger: FlutterBinaryMessenger
|
||||
private weak var parentViewController: UIViewController?
|
||||
@@ -130,9 +122,6 @@ final class InlinePhotoPickerPlatformView: NSObject, FlutterPlatformView {
|
||||
}
|
||||
pickerViewController = picker
|
||||
containerView.layoutIfNeeded()
|
||||
EmbeddedPhotoPickerLayout.applyPreferredScale {
|
||||
picker.zoomIn()
|
||||
}
|
||||
}
|
||||
|
||||
private func exportPickerResult(_ result: PHPickerResult) async throws -> String {
|
||||
|
||||
@@ -17,8 +17,6 @@ final class NativeAttachmentPopoverViewController:
|
||||
case camera
|
||||
}
|
||||
|
||||
private typealias ContentPreparation = (@escaping () -> Void) -> Void
|
||||
|
||||
private let channel: FlutterMethodChannel
|
||||
private let expandedWidth: CGFloat
|
||||
private let maximumMenuHeight: CGFloat
|
||||
@@ -85,17 +83,29 @@ final class NativeAttachmentPopoverViewController:
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = .clear
|
||||
view.layer.cornerRadius = 22
|
||||
view.layer.cornerRadius = NativeAttachmentPopoverStyle.cornerRadius
|
||||
view.layer.cornerCurve = .continuous
|
||||
view.clipsToBounds = true
|
||||
view.layer.borderColor = UIColor.black.withAlphaComponent(0.04).cgColor
|
||||
view.layer.borderWidth = NativeAttachmentPopoverStyle.borderWidth
|
||||
view.layer.shadowColor = UIColor.black.cgColor
|
||||
view.layer.shadowOpacity = NativeAttachmentPopoverStyle.shadowOpacity
|
||||
view.layer.shadowRadius = NativeAttachmentPopoverStyle.shadowRadius
|
||||
view.layer.shadowOffset = NativeAttachmentPopoverStyle.shadowOffset
|
||||
view.clipsToBounds = false
|
||||
|
||||
let glassEffect = UIGlassEffect(style: .regular)
|
||||
glassEffect.isInteractive = true
|
||||
let glassView = UIVisualEffectView(effect: glassEffect)
|
||||
glassView.translatesAutoresizingMaskIntoConstraints = false
|
||||
glassView.layer.cornerRadius = NativeAttachmentPopoverStyle.cornerRadius
|
||||
glassView.layer.cornerCurve = .continuous
|
||||
glassView.clipsToBounds = true
|
||||
view.addSubview(glassView)
|
||||
|
||||
contentHost.translatesAutoresizingMaskIntoConstraints = false
|
||||
contentHost.layer.cornerRadius = NativeAttachmentPopoverStyle.cornerRadius
|
||||
contentHost.layer.cornerCurve = .continuous
|
||||
contentHost.clipsToBounds = true
|
||||
view.addSubview(contentHost)
|
||||
NSLayoutConstraint.activate([
|
||||
glassView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
@@ -114,6 +124,11 @@ final class NativeAttachmentPopoverViewController:
|
||||
|
||||
override func viewDidLayoutSubviews() {
|
||||
super.viewDidLayoutSubviews()
|
||||
view.layer.shadowPath =
|
||||
UIBezierPath(
|
||||
roundedRect: view.bounds,
|
||||
cornerRadius: NativeAttachmentPopoverStyle.cornerRadius
|
||||
).cgPath
|
||||
cameraPreviewLayer?.frame = cameraPreviewView?.bounds ?? .zero
|
||||
}
|
||||
|
||||
@@ -198,21 +213,21 @@ final class NativeAttachmentPopoverViewController:
|
||||
makeNativeAttachmentMenuButton(
|
||||
title: "Camera",
|
||||
symbol: "camera",
|
||||
action: UIAction { [weak self] _ in self?.showCamera() }
|
||||
action: { [weak self] in self?.showCamera() }
|
||||
)
|
||||
)
|
||||
stack.addArrangedSubview(
|
||||
makeNativeAttachmentMenuButton(
|
||||
title: "Photos",
|
||||
symbol: "photo.on.rectangle.angled",
|
||||
action: UIAction { [weak self] _ in self?.showPhotos() }
|
||||
action: { [weak self] in self?.showPhotos() }
|
||||
)
|
||||
)
|
||||
stack.addArrangedSubview(
|
||||
makeNativeAttachmentMenuButton(
|
||||
title: "Video",
|
||||
symbol: "video",
|
||||
action: UIAction { [weak self] _ in
|
||||
action: { [weak self] in
|
||||
self?.finish(method: "pickVideo")
|
||||
}
|
||||
)
|
||||
@@ -221,7 +236,7 @@ final class NativeAttachmentPopoverViewController:
|
||||
makeNativeAttachmentMenuButton(
|
||||
title: "Files",
|
||||
symbol: "doc",
|
||||
action: UIAction { [weak self] _ in
|
||||
action: { [weak self] in
|
||||
self?.finish(method: "pickFiles")
|
||||
}
|
||||
)
|
||||
@@ -280,14 +295,14 @@ final class NativeAttachmentPopoverViewController:
|
||||
title: nil,
|
||||
symbol: "chevron.left",
|
||||
accessibilityLabel: "Back to attachment options",
|
||||
action: UIAction { [weak self] _ in self?.showMenu() }
|
||||
action: { [weak self] in self?.showMenu() }
|
||||
)
|
||||
let actionButton = makeGlassControl(
|
||||
title: "All Photos",
|
||||
symbol: nil,
|
||||
accessibilityLabel: "All Photos",
|
||||
prominent: true,
|
||||
action: UIAction { [weak self] _ in self?.performPhotoAction() }
|
||||
action: { [weak self] in self?.performPhotoAction() }
|
||||
)
|
||||
photoActionButton = actionButton
|
||||
addBottomControls(
|
||||
@@ -296,27 +311,7 @@ final class NativeAttachmentPopoverViewController:
|
||||
trailing: actionButton
|
||||
)
|
||||
|
||||
transition(
|
||||
to: .photos,
|
||||
content: container,
|
||||
preparation: { [weak picker] reveal in
|
||||
guard let picker else {
|
||||
reveal()
|
||||
return
|
||||
}
|
||||
// PHPicker ignores scale changes while its remote grid is still
|
||||
// adapting to the compact menu bounds. Give it one main-loop turn at
|
||||
// the final popover size, apply the scale offscreen, then reveal it.
|
||||
DispatchQueue.main.async {
|
||||
picker.view.layoutIfNeeded()
|
||||
EmbeddedPhotoPickerLayout.applyPreferredScale {
|
||||
picker.zoomIn()
|
||||
picker.view.layoutIfNeeded()
|
||||
}
|
||||
DispatchQueue.main.async(execute: reveal)
|
||||
}
|
||||
}
|
||||
)
|
||||
transition(to: .photos, content: container)
|
||||
}
|
||||
|
||||
private func showCamera() {
|
||||
@@ -353,7 +348,7 @@ final class NativeAttachmentPopoverViewController:
|
||||
title: nil,
|
||||
symbol: "chevron.left",
|
||||
accessibilityLabel: "Back to attachment options",
|
||||
action: UIAction { [weak self] _ in self?.showMenu() }
|
||||
action: { [weak self] in self?.showMenu() }
|
||||
)
|
||||
let captureButton = makeCameraCaptureButton()
|
||||
cameraCaptureButton = captureButton
|
||||
@@ -415,7 +410,6 @@ final class NativeAttachmentPopoverViewController:
|
||||
private func transition(
|
||||
to nextSurface: Surface,
|
||||
content nextView: UIView,
|
||||
preparation: ContentPreparation? = nil,
|
||||
completion: (() -> Void)? = nil
|
||||
) {
|
||||
let previousView = visibleContentView
|
||||
@@ -485,11 +479,7 @@ final class NativeAttachmentPopoverViewController:
|
||||
}
|
||||
}
|
||||
|
||||
if let preparation {
|
||||
preparation(reveal)
|
||||
} else {
|
||||
reveal()
|
||||
}
|
||||
reveal()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -498,7 +488,7 @@ final class NativeAttachmentPopoverViewController:
|
||||
symbol: String?,
|
||||
accessibilityLabel: String,
|
||||
prominent: Bool = false,
|
||||
action: UIAction
|
||||
action: @escaping () -> Void
|
||||
) -> UIButton {
|
||||
var configuration =
|
||||
prominent
|
||||
@@ -513,20 +503,37 @@ final class NativeAttachmentPopoverViewController:
|
||||
}
|
||||
configuration.imagePadding = 8
|
||||
configuration.baseForegroundColor = .white
|
||||
configuration.titleTextAttributesTransformer =
|
||||
UIConfigurationTextAttributesTransformer { attributes in
|
||||
var interAttributes = attributes
|
||||
interAttributes.font = NativeAttachmentMenuTypography.font(
|
||||
forTextStyle: .body
|
||||
)
|
||||
return interAttributes
|
||||
}
|
||||
configuration.contentInsets = NSDirectionalEdgeInsets(
|
||||
top: 11,
|
||||
leading: 15,
|
||||
bottom: 11,
|
||||
trailing: 15
|
||||
)
|
||||
let button = UIButton(configuration: configuration, primaryAction: action)
|
||||
let button = UIButton(
|
||||
configuration: configuration,
|
||||
primaryAction: UIAction { _ in
|
||||
UISelectionFeedbackGenerator().selectionChanged()
|
||||
action()
|
||||
}
|
||||
)
|
||||
button.accessibilityLabel = accessibilityLabel
|
||||
return button
|
||||
}
|
||||
|
||||
private func makeCameraCaptureButton() -> UIButton {
|
||||
let button = UIButton(
|
||||
primaryAction: UIAction { [weak self] _ in self?.capturePhoto() }
|
||||
primaryAction: UIAction { [weak self] _ in
|
||||
UISelectionFeedbackGenerator().selectionChanged()
|
||||
self?.capturePhoto()
|
||||
}
|
||||
)
|
||||
button.accessibilityLabel = "Take photo"
|
||||
button.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import CoreText
|
||||
import Flutter
|
||||
import UIKit
|
||||
|
||||
@@ -276,7 +277,7 @@ enum NativeAttachmentMenuLayout {
|
||||
static func itemHeight(
|
||||
compatibleWith traitCollection: UITraitCollection
|
||||
) -> CGFloat {
|
||||
let labelHeight = UIFont.preferredFont(
|
||||
let labelHeight = NativeAttachmentMenuTypography.font(
|
||||
forTextStyle: labelTextStyle,
|
||||
compatibleWith: traitCollection
|
||||
).lineHeight
|
||||
@@ -313,12 +314,71 @@ enum NativeAttachmentMenuLayout {
|
||||
}
|
||||
}
|
||||
|
||||
enum NativeAttachmentMenuTypography {
|
||||
static let interPostScriptName = "InterVariable"
|
||||
|
||||
private static let registeredInter: Bool = {
|
||||
let fontURL = Bundle.main.bundleURL
|
||||
.appendingPathComponent("Frameworks")
|
||||
.appendingPathComponent("App.framework")
|
||||
.appendingPathComponent("flutter_assets")
|
||||
.appendingPathComponent("assets")
|
||||
.appendingPathComponent("fonts")
|
||||
.appendingPathComponent("InterVariable.ttf")
|
||||
guard FileManager.default.fileExists(atPath: fontURL.path) else {
|
||||
return false
|
||||
}
|
||||
return CTFontManagerRegisterFontsForURL(
|
||||
fontURL as CFURL,
|
||||
.process,
|
||||
nil
|
||||
)
|
||||
}()
|
||||
|
||||
static func font(
|
||||
forTextStyle textStyle: UIFont.TextStyle,
|
||||
compatibleWith traitCollection: UITraitCollection? = nil
|
||||
) -> UIFont {
|
||||
_ = registeredInter
|
||||
let scaledPointSize = UIFontMetrics(forTextStyle: textStyle).scaledValue(
|
||||
for: 20,
|
||||
compatibleWith: traitCollection
|
||||
)
|
||||
let preferredFont = UIFont.preferredFont(
|
||||
forTextStyle: textStyle,
|
||||
compatibleWith: traitCollection
|
||||
)
|
||||
guard
|
||||
let interFont = UIFont(
|
||||
name: interPostScriptName,
|
||||
size: scaledPointSize
|
||||
)
|
||||
else {
|
||||
return preferredFont
|
||||
}
|
||||
return interFont
|
||||
}
|
||||
}
|
||||
|
||||
enum NativeAttachmentPopoverStyle {
|
||||
static let cornerRadius: CGFloat = 20
|
||||
static let shadowOpacity: Float = 0.18
|
||||
static let shadowRadius: CGFloat = 12
|
||||
static let shadowOffset = CGSize(width: 0, height: 6)
|
||||
static let borderWidth: CGFloat = 1
|
||||
}
|
||||
|
||||
func makeNativeAttachmentMenuButton(
|
||||
title: String,
|
||||
symbol: String,
|
||||
action: UIAction
|
||||
action: @escaping () -> Void
|
||||
) -> UIButton {
|
||||
let button = UIButton(primaryAction: action)
|
||||
let button = UIButton(
|
||||
primaryAction: UIAction { _ in
|
||||
UISelectionFeedbackGenerator().selectionChanged()
|
||||
action()
|
||||
}
|
||||
)
|
||||
button.accessibilityLabel = title
|
||||
|
||||
let symbolConfiguration = UIImage.SymbolConfiguration(
|
||||
@@ -338,7 +398,7 @@ func makeNativeAttachmentMenuButton(
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.text = title
|
||||
titleLabel.textColor = .label
|
||||
titleLabel.font = .preferredFont(
|
||||
titleLabel.font = NativeAttachmentMenuTypography.font(
|
||||
forTextStyle: NativeAttachmentMenuLayout.labelTextStyle
|
||||
)
|
||||
titleLabel.adjustsFontForContentSizeCategory = true
|
||||
|
||||
@@ -127,19 +127,6 @@ class RunnerTests: XCTestCase {
|
||||
)
|
||||
}
|
||||
|
||||
func testEmbeddedPhotoPickerAppliesOneZoomInStepWithoutAnimation() {
|
||||
var zoomInCalls = 0
|
||||
var animationsWereEnabled = true
|
||||
|
||||
EmbeddedPhotoPickerLayout.applyPreferredScale {
|
||||
zoomInCalls += 1
|
||||
animationsWereEnabled = UIView.areAnimationsEnabled
|
||||
}
|
||||
|
||||
XCTAssertEqual(zoomInCalls, 1)
|
||||
XCTAssertFalse(animationsWereEnabled)
|
||||
}
|
||||
|
||||
func testNativeAttachmentMenuUsesRoomyRowsAndInsets() {
|
||||
let traits = UITraitCollection(preferredContentSizeCategory: .large)
|
||||
let size = NativeAttachmentMenuLayout.size(compatibleWith: traits)
|
||||
@@ -155,6 +142,28 @@ class RunnerTests: XCTestCase {
|
||||
XCTAssertEqual(NativeAttachmentMenuLayout.labelTextStyle, .title3)
|
||||
}
|
||||
|
||||
func testNativeAttachmentMenuUsesInterAndSharedPopoverChrome() {
|
||||
let font = NativeAttachmentMenuTypography.font(
|
||||
forTextStyle: NativeAttachmentMenuLayout.labelTextStyle
|
||||
)
|
||||
var didSelect = false
|
||||
let button = makeNativeAttachmentMenuButton(
|
||||
title: "Photos",
|
||||
symbol: "photo",
|
||||
action: { didSelect = true }
|
||||
)
|
||||
let titleLabel = button.subviews.compactMap { $0 as? UILabel }.first
|
||||
|
||||
XCTAssertTrue(font.fontName.hasPrefix("Inter"))
|
||||
XCTAssertTrue(titleLabel?.font.fontName.hasPrefix("Inter") == true)
|
||||
XCTAssertEqual(NativeAttachmentPopoverStyle.cornerRadius, 20)
|
||||
XCTAssertEqual(NativeAttachmentPopoverStyle.borderWidth, 1)
|
||||
XCTAssertEqual(NativeAttachmentPopoverStyle.shadowOpacity, 0.18)
|
||||
|
||||
button.sendActions(for: .primaryActionTriggered)
|
||||
XCTAssertTrue(didSelect)
|
||||
}
|
||||
|
||||
func testNativeAttachmentMenuGrowsAndScrollsForAccessibilityText() {
|
||||
let traits = UITraitCollection(
|
||||
preferredContentSizeCategory: .accessibilityExtraExtraExtraLarge
|
||||
|
||||
@@ -37,6 +37,18 @@ part 'activity_page/inbox_row.dart';
|
||||
part 'activity_page/lists.dart';
|
||||
part 'activity_page/status_views.dart';
|
||||
|
||||
EdgeInsets _activityScrollPadding(
|
||||
BuildContext context, {
|
||||
double horizontal = 0,
|
||||
double top = Grid.xxs,
|
||||
double bottom = Grid.xxs,
|
||||
}) => EdgeInsets.fromLTRB(
|
||||
horizontal,
|
||||
top,
|
||||
horizontal,
|
||||
MediaQuery.paddingOf(context).bottom + bottom,
|
||||
);
|
||||
|
||||
/// Conversation-oriented Activity inbox.
|
||||
///
|
||||
/// Matches desktop's Home inbox item design and semantics (see
|
||||
@@ -264,7 +276,7 @@ class ActivityPage extends HookConsumerWidget {
|
||||
body = RefreshIndicator(
|
||||
onRefresh: refresh,
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(vertical: Grid.xxs),
|
||||
padding: _activityScrollPadding(context),
|
||||
itemCount: visibleItems.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = visibleItems[index];
|
||||
@@ -318,7 +330,9 @@ class ActivityPage extends HookConsumerWidget {
|
||||
],
|
||||
),
|
||||
body: SafeArea(
|
||||
key: const ValueKey('activity-content-safe-area'),
|
||||
top: false,
|
||||
bottom: false,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
top: frostedAppBarHeight(context, titleStyle: headerTitleStyle),
|
||||
|
||||
@@ -39,15 +39,6 @@ class _FilterMenuButton extends StatelessWidget {
|
||||
alignment: AnchoredPopoverAlignment.start,
|
||||
offset: const Offset(0, Grid.half),
|
||||
menuPadding: const EdgeInsets.symmetric(vertical: Grid.half),
|
||||
color: context.colors.surface.withValues(alpha: 0.98),
|
||||
elevation: 8,
|
||||
shadowColor: context.colors.shadow.withValues(alpha: 0.18),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(Radii.card),
|
||||
side: BorderSide(
|
||||
color: context.colors.outlineVariant.withValues(alpha: 0.45),
|
||||
),
|
||||
),
|
||||
surfaceKey: const ValueKey('activity-filter-popover'),
|
||||
items: [
|
||||
for (final entry in _filterLabels.entries)
|
||||
@@ -183,13 +174,6 @@ class _InboxOptionsButton extends StatelessWidget {
|
||||
context: buttonContext,
|
||||
width: 216,
|
||||
alignment: AnchoredPopoverAlignment.end,
|
||||
color: context.colors.surface,
|
||||
elevation: 4,
|
||||
shadowColor: context.colors.shadow.withValues(alpha: 0.18),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(Radii.md),
|
||||
side: BorderSide(color: context.colors.outline),
|
||||
),
|
||||
surfaceKey: const ValueKey('activity-options-popover'),
|
||||
items: [
|
||||
PopupMenuItem(
|
||||
|
||||
@@ -36,7 +36,7 @@ class _RemindersList extends ConsumerWidget {
|
||||
return RefreshIndicator(
|
||||
onRefresh: onRefresh,
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(vertical: Grid.xxs),
|
||||
padding: _activityScrollPadding(context),
|
||||
itemCount: reminders.length,
|
||||
itemBuilder: (context, index) {
|
||||
final reminder = reminders[index];
|
||||
@@ -97,7 +97,7 @@ class _DraftsList extends StatelessWidget {
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(vertical: Grid.xxs),
|
||||
padding: _activityScrollPadding(context),
|
||||
itemCount: drafts.length,
|
||||
itemBuilder: (context, index) {
|
||||
final draft = drafts[index];
|
||||
|
||||
@@ -6,7 +6,12 @@ class _LoadingSkeleton extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListView.separated(
|
||||
padding: const EdgeInsets.all(Grid.gutter),
|
||||
padding: _activityScrollPadding(
|
||||
context,
|
||||
horizontal: Grid.gutter,
|
||||
top: Grid.gutter,
|
||||
bottom: Grid.gutter,
|
||||
),
|
||||
itemCount: 8,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: Grid.xs),
|
||||
itemBuilder: (context, _) => Row(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' show min;
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart' show ScrollDirection;
|
||||
@@ -32,6 +33,7 @@ import 'channel_typing_provider.dart';
|
||||
import 'channel_typing_indicator.dart';
|
||||
import 'channels_provider.dart';
|
||||
import 'compose_bar.dart';
|
||||
import 'composer_dock_size_reporter.dart';
|
||||
import 'date_formatters.dart';
|
||||
import 'day_divider.dart';
|
||||
import 'dm_channel_labels.dart';
|
||||
@@ -125,6 +127,7 @@ class ChannelDetailPage extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final composerDockHeight = useState(0.0);
|
||||
final detailsAsync = ref.watch(channelDetailsProvider(channel.id));
|
||||
final channelsAsync = ref.watch(channelsProvider);
|
||||
final messagesState = ref.watch(channelMessagesProvider(channel.id));
|
||||
@@ -156,6 +159,10 @@ class ChannelDetailPage extends HookConsumerWidget {
|
||||
channel;
|
||||
final resolvedChannel =
|
||||
detailsAsync.whenData(baseChannel.mergeDetails).value ?? baseChannel;
|
||||
final showsComposer =
|
||||
!resolvedChannel.isForum &&
|
||||
resolvedChannel.isMember &&
|
||||
!resolvedChannel.isArchived;
|
||||
final messagesNotifier = ref.read(
|
||||
channelMessagesProvider(channel.id).notifier,
|
||||
);
|
||||
@@ -293,122 +300,162 @@ class ChannelDetailPage extends HookConsumerWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
body: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Expanded(
|
||||
child: resolvedChannel.isForum
|
||||
? Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
ForumPostsView(
|
||||
channel: resolvedChannel,
|
||||
currentPubkey: currentPubkey,
|
||||
),
|
||||
if (showConnectionSkeleton.value)
|
||||
Positioned(
|
||||
top:
|
||||
frostedAppBarHeight(
|
||||
Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: resolvedChannel.isForum
|
||||
? Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
ForumPostsView(
|
||||
channel: resolvedChannel,
|
||||
currentPubkey: currentPubkey,
|
||||
),
|
||||
if (showConnectionSkeleton.value)
|
||||
Positioned(
|
||||
top:
|
||||
frostedAppBarHeight(
|
||||
context,
|
||||
titleContentHeight:
|
||||
appBarTitleContentHeight,
|
||||
) +
|
||||
Grid.xs,
|
||||
left: Grid.gutter,
|
||||
right: Grid.gutter,
|
||||
child: _ForumConnectionSkeleton(
|
||||
status: sessionStatus,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: SkeletonReveal(
|
||||
loading:
|
||||
showInitialConnectionSkeleton ||
|
||||
showConnectionSkeleton.value ||
|
||||
messagesState.isLoading,
|
||||
shimmerEnabled:
|
||||
sessionStatus != SessionStatus.disconnected,
|
||||
skeleton: _MessageTimelineSkeleton(
|
||||
appBarTitleContentHeight: appBarTitleContentHeight,
|
||||
status: sessionStatus,
|
||||
),
|
||||
content: messagesState.when(
|
||||
loading: SizedBox.shrink,
|
||||
error: (e, _) => Padding(
|
||||
padding: EdgeInsets.only(
|
||||
top: frostedAppBarHeight(
|
||||
context,
|
||||
titleContentHeight: appBarTitleContentHeight,
|
||||
) +
|
||||
Grid.xs,
|
||||
left: Grid.gutter,
|
||||
right: Grid.gutter,
|
||||
child: _ForumConnectionSkeleton(
|
||||
status: sessionStatus,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: SkeletonReveal(
|
||||
loading:
|
||||
showInitialConnectionSkeleton ||
|
||||
showConnectionSkeleton.value ||
|
||||
messagesState.isLoading,
|
||||
shimmerEnabled: sessionStatus != SessionStatus.disconnected,
|
||||
skeleton: _MessageTimelineSkeleton(
|
||||
appBarTitleContentHeight: appBarTitleContentHeight,
|
||||
status: sessionStatus,
|
||||
),
|
||||
content: messagesState.when(
|
||||
loading: SizedBox.shrink,
|
||||
error: (e, _) => Padding(
|
||||
padding: EdgeInsets.only(
|
||||
top: frostedAppBarHeight(
|
||||
context,
|
||||
titleContentHeight: appBarTitleContentHeight,
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'Failed to load messages',
|
||||
style: context.textTheme.bodyMedium?.copyWith(
|
||||
color: context.colors.error,
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'Failed to load messages',
|
||||
style: context.textTheme.bodyMedium?.copyWith(
|
||||
color: context.colors.error,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
data: (events) {
|
||||
final messages = formatTimeline(
|
||||
events,
|
||||
currentPubkey: currentPubkey,
|
||||
);
|
||||
final summaries = ref
|
||||
.read(
|
||||
channelMessagesProvider(channel.id).notifier,
|
||||
)
|
||||
.threadSummaries;
|
||||
final entries = buildMainTimelineEntries(
|
||||
messages,
|
||||
relaySummaries: summaries,
|
||||
);
|
||||
return _MessageList(
|
||||
entries: entries,
|
||||
allMessages: messages,
|
||||
initialMessageId: initialMessageId,
|
||||
initialThreadRootId: initialThreadRootId,
|
||||
channelId: channel.id,
|
||||
currentPubkey: currentPubkey,
|
||||
isMember: resolvedChannel.isMember,
|
||||
isArchived: resolvedChannel.isArchived,
|
||||
appBarTitleContentHeight:
|
||||
appBarTitleContentHeight,
|
||||
composerBottomInset: showsComposer
|
||||
? composerDockHeight.value
|
||||
: 0,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
data: (events) {
|
||||
final messages = formatTimeline(
|
||||
events,
|
||||
currentPubkey: currentPubkey,
|
||||
);
|
||||
final summaries = ref
|
||||
.read(channelMessagesProvider(channel.id).notifier)
|
||||
.threadSummaries;
|
||||
final entries = buildMainTimelineEntries(
|
||||
messages,
|
||||
relaySummaries: summaries,
|
||||
);
|
||||
return _MessageList(
|
||||
entries: entries,
|
||||
allMessages: messages,
|
||||
initialMessageId: initialMessageId,
|
||||
initialThreadRootId: initialThreadRootId,
|
||||
channelId: channel.id,
|
||||
currentPubkey: currentPubkey,
|
||||
isMember: resolvedChannel.isMember,
|
||||
isArchived: resolvedChannel.isArchived,
|
||||
appBarTitleContentHeight: appBarTitleContentHeight,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!resolvedChannel.isForum &&
|
||||
(!resolvedChannel.isMember ||
|
||||
resolvedChannel.isArchived)) ...[
|
||||
AnimatedSize(
|
||||
duration: MediaQuery.disableAnimationsOf(context)
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 180),
|
||||
curve: Curves.easeOutCubic,
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: typingEntries.isEmpty
|
||||
? const SizedBox.shrink()
|
||||
: ChannelTypingIndicator(entries: typingEntries),
|
||||
),
|
||||
if (!resolvedChannel.isDm)
|
||||
_ReadOnlyNotice(channel: resolvedChannel),
|
||||
],
|
||||
],
|
||||
),
|
||||
if (!resolvedChannel.isForum)
|
||||
AnimatedSize(
|
||||
duration: MediaQuery.disableAnimationsOf(context)
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 180),
|
||||
curve: Curves.easeOutCubic,
|
||||
if (showsComposer)
|
||||
Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: typingEntries.isEmpty
|
||||
? const SizedBox.shrink()
|
||||
: ChannelTypingIndicator(entries: typingEntries),
|
||||
child: ComposerDockSizeReporter(
|
||||
key: const ValueKey('channel-composer-dock'),
|
||||
onHeightChanged: (height) {
|
||||
if ((composerDockHeight.value - height).abs() < 0.5) return;
|
||||
composerDockHeight.value = height;
|
||||
},
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
AnimatedSize(
|
||||
duration: MediaQuery.disableAnimationsOf(context)
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 180),
|
||||
curve: Curves.easeOutCubic,
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: typingEntries.isEmpty
|
||||
? const SizedBox.shrink()
|
||||
: ChannelTypingIndicator(entries: typingEntries),
|
||||
),
|
||||
ComposeBar(
|
||||
channelId: channel.id,
|
||||
channelName: resolvedChannel.isDm
|
||||
? ''
|
||||
: resolvedChannel.name,
|
||||
onSend:
|
||||
(
|
||||
content,
|
||||
mentionPubkeys, {
|
||||
mediaTags = const <List<String>>[],
|
||||
}) => ref
|
||||
.read(sendMessageProvider)
|
||||
.call(
|
||||
channelId: channel.id,
|
||||
content: content,
|
||||
mentionPubkeys: mentionPubkeys,
|
||||
mediaTags: mediaTags,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!resolvedChannel.isForum &&
|
||||
resolvedChannel.isMember &&
|
||||
!resolvedChannel.isArchived)
|
||||
ComposeBar(
|
||||
channelId: channel.id,
|
||||
channelName: resolvedChannel.isDm ? '' : resolvedChannel.name,
|
||||
onSend:
|
||||
(
|
||||
content,
|
||||
mentionPubkeys, {
|
||||
mediaTags = const <List<String>>[],
|
||||
}) => ref
|
||||
.read(sendMessageProvider)
|
||||
.call(
|
||||
channelId: channel.id,
|
||||
content: content,
|
||||
mentionPubkeys: mentionPubkeys,
|
||||
mediaTags: mediaTags,
|
||||
),
|
||||
)
|
||||
else if (!resolvedChannel.isDm &&
|
||||
(!resolvedChannel.isMember || resolvedChannel.isArchived))
|
||||
_ReadOnlyNotice(channel: resolvedChannel),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -10,6 +10,7 @@ class _MessageList extends HookConsumerWidget {
|
||||
final bool isMember;
|
||||
final bool isArchived;
|
||||
final double appBarTitleContentHeight;
|
||||
final double composerBottomInset;
|
||||
|
||||
const _MessageList({
|
||||
required this.entries,
|
||||
@@ -21,6 +22,7 @@ class _MessageList extends HookConsumerWidget {
|
||||
required this.isMember,
|
||||
required this.isArchived,
|
||||
required this.appBarTitleContentHeight,
|
||||
required this.composerBottomInset,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -259,7 +261,7 @@ class _MessageList extends HookConsumerWidget {
|
||||
context,
|
||||
titleContentHeight: appBarTitleContentHeight,
|
||||
),
|
||||
bottom: 0,
|
||||
bottom: composerBottomInset,
|
||||
),
|
||||
itemCount: displayEntries.length + (isLoadingOlder.value ? 1 : 0),
|
||||
itemBuilder: (context, index) {
|
||||
@@ -356,21 +358,11 @@ class _MessageList extends HookConsumerWidget {
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: Grid.xs,
|
||||
bottom: composerBottomInset + Grid.xs,
|
||||
child: Center(
|
||||
child: FilledButton.icon(
|
||||
child: _JumpToLatestButton(
|
||||
key: const ValueKey('channel-jump-to-latest'),
|
||||
onPressed: scrollToLatest,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: context.colors.primaryContainer,
|
||||
foregroundColor: context.colors.onPrimaryContainer,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Grid.gutter,
|
||||
vertical: Grid.xxs,
|
||||
),
|
||||
),
|
||||
icon: const Icon(LucideIcons.arrowDown, size: 16),
|
||||
label: const Text('Latest'),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -378,3 +370,64 @@ class _MessageList extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _JumpToLatestButton extends StatelessWidget {
|
||||
final VoidCallback onPressed;
|
||||
|
||||
const _JumpToLatestButton({required this.onPressed, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final borderRadius = BorderRadius.circular(Radii.full);
|
||||
return Semantics(
|
||||
button: true,
|
||||
child: ClipRRect(
|
||||
borderRadius: borderRadius,
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
|
||||
child: Container(
|
||||
key: const ValueKey('channel-jump-to-latest-surface'),
|
||||
decoration: BoxDecoration(
|
||||
color: context.colors.surface.withValues(alpha: 0.5),
|
||||
borderRadius: borderRadius,
|
||||
border: Border.all(
|
||||
color: Colors.black.withValues(alpha: 0.04),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Material(
|
||||
type: MaterialType.transparency,
|
||||
child: InkWell(
|
||||
onTap: onPressed,
|
||||
borderRadius: borderRadius,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Grid.gutter,
|
||||
vertical: Grid.xxs,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.arrowDown,
|
||||
size: 16,
|
||||
color: context.colors.onSurface,
|
||||
),
|
||||
const SizedBox(width: Grid.half),
|
||||
Text(
|
||||
'Latest',
|
||||
style: context.textTheme.labelLarge?.copyWith(
|
||||
color: context.colors.onSurface,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,12 +27,18 @@ class _SystemMessageRow extends ConsumerWidget {
|
||||
final userCache = ref.watch(userCacheProvider);
|
||||
final sourceMessages = groupedMessages ?? [message];
|
||||
final groupedMembership = _membershipDisplayEvent(sourceMessages);
|
||||
final channelCreator = systemEvent.type == SystemEventType.channelCreated
|
||||
? systemEvent.actorPubkey?.trim()
|
||||
: null;
|
||||
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 ||
|
||||
(channelCreator != null && channelCreator.isNotEmpty);
|
||||
(messageStyleActor != null && messageStyleActor.isNotEmpty);
|
||||
|
||||
String resolveLabel(String? pubkey) {
|
||||
if (pubkey == null) return 'Someone';
|
||||
@@ -102,13 +108,15 @@ class _SystemMessageRow extends ConsumerWidget {
|
||||
resolveLabel: resolveLabel,
|
||||
userCache: userCache,
|
||||
)
|
||||
else if (channelCreator != null && channelCreator.isNotEmpty)
|
||||
else if (messageStyleActor != null &&
|
||||
messageStyleActor.isNotEmpty &&
|
||||
messageStyleAction != null)
|
||||
_MessageStyleSystemMessageContent(
|
||||
displayPubkey: channelCreator,
|
||||
displayPubkey: messageStyleActor,
|
||||
createdAt: message.createdAt,
|
||||
resolveLabel: resolveLabel,
|
||||
userCache: userCache,
|
||||
actionSpans: const [TextSpan(text: 'created this channel')],
|
||||
actionSpans: [TextSpan(text: messageStyleAction)],
|
||||
)
|
||||
else
|
||||
Row(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../../shared/theme/theme.dart';
|
||||
@@ -72,13 +73,11 @@ class ChannelTypingIndicator extends ConsumerWidget {
|
||||
),
|
||||
const SizedBox(width: Grid.xxs),
|
||||
Flexible(
|
||||
child: Text(
|
||||
child: _TypingTextShimmer(
|
||||
text,
|
||||
style: context.textTheme.labelSmall?.copyWith(
|
||||
color: context.colors.primary,
|
||||
fontStyle: FontStyle.italic,
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -87,3 +86,62 @@ class ChannelTypingIndicator extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TypingTextShimmer extends HookWidget {
|
||||
final String text;
|
||||
final TextStyle? style;
|
||||
|
||||
const _TypingTextShimmer(this.text, {this.style});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final animation = useAnimationController(
|
||||
duration: const Duration(milliseconds: 2600),
|
||||
);
|
||||
final reducedMotion = MediaQuery.disableAnimationsOf(context);
|
||||
final baseColor = style?.color ?? context.colors.onSurfaceVariant;
|
||||
final highlightColor =
|
||||
Color.lerp(context.colors.surface, baseColor, 0.4) ?? baseColor;
|
||||
|
||||
useEffect(() {
|
||||
if (reducedMotion) {
|
||||
animation
|
||||
..stop()
|
||||
..value = 0;
|
||||
} else {
|
||||
animation.repeat();
|
||||
}
|
||||
return animation.stop;
|
||||
}, [animation, reducedMotion]);
|
||||
|
||||
final label = Text(text, style: style, overflow: TextOverflow.ellipsis);
|
||||
if (reducedMotion) return label;
|
||||
|
||||
return RepaintBoundary(
|
||||
child: AnimatedBuilder(
|
||||
animation: animation,
|
||||
child: label,
|
||||
builder: (context, child) {
|
||||
final center = 1.5 - (animation.value * 3);
|
||||
return ShaderMask(
|
||||
key: const ValueKey('channel-typing-shimmer'),
|
||||
blendMode: BlendMode.srcIn,
|
||||
shaderCallback: (bounds) => LinearGradient(
|
||||
begin: Alignment(center - 1, 0),
|
||||
end: Alignment(center + 1, 0),
|
||||
colors: [
|
||||
baseColor,
|
||||
baseColor,
|
||||
highlightColor,
|
||||
baseColor,
|
||||
baseColor,
|
||||
],
|
||||
stops: const [0, 0.34, 0.5, 0.66, 1],
|
||||
).createShader(bounds),
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
part of '../channels_page.dart';
|
||||
|
||||
const _sectionMenuItemPadding = EdgeInsets.fromLTRB(Grid.xs, 0, Grid.twelve, 0);
|
||||
|
||||
class _CustomChannelSection extends StatelessWidget {
|
||||
final ChannelSection section;
|
||||
final List<Channel> channels;
|
||||
@@ -178,32 +180,42 @@ class _CustomSectionHeader extends ConsumerWidget {
|
||||
context: buttonContext,
|
||||
width: 216,
|
||||
alignment: AnchoredPopoverAlignment.end,
|
||||
color: context.colors.surface,
|
||||
elevation: 4,
|
||||
shadowColor: context.colors.shadow.withValues(alpha: 0.18),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(Radii.md),
|
||||
side: BorderSide(color: context.colors.outline),
|
||||
),
|
||||
surfaceKey: ValueKey('section-popover-${section.id}'),
|
||||
items: [
|
||||
const PopupMenuItem(
|
||||
value: 'rename',
|
||||
child: Text('Rename'),
|
||||
padding: _sectionMenuItemPadding,
|
||||
child: _SectionMenuItemContent(
|
||||
icon: LucideIcons.pencil,
|
||||
label: 'Rename section',
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'move_up',
|
||||
enabled: !isFirst,
|
||||
child: const Text('Move Up'),
|
||||
padding: _sectionMenuItemPadding,
|
||||
child: const _SectionMenuItemContent(
|
||||
icon: LucideIcons.arrowUp,
|
||||
label: 'Move up',
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'move_down',
|
||||
enabled: !isLast,
|
||||
child: const Text('Move Down'),
|
||||
padding: _sectionMenuItemPadding,
|
||||
child: const _SectionMenuItemContent(
|
||||
icon: LucideIcons.arrowDown,
|
||||
label: 'Move down',
|
||||
),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
PopupMenuItem(
|
||||
value: 'delete',
|
||||
child: Text('Delete'),
|
||||
padding: _sectionMenuItemPadding,
|
||||
child: _SectionMenuItemContent(
|
||||
icon: LucideIcons.trash2,
|
||||
label: 'Delete section',
|
||||
color: context.colors.error,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -229,6 +241,36 @@ class _CustomSectionHeader extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionMenuItemContent extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final Color? color;
|
||||
|
||||
const _SectionMenuItemContent({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
this.color,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
Icon(icon, size: 16, color: color),
|
||||
const SizedBox(width: Grid.xxs),
|
||||
Flexible(
|
||||
child: Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: color == null ? null : TextStyle(color: color),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
CustomEmoji? _resolveCustomEmoji(String icon, List<CustomEmoji> palette) {
|
||||
if (!icon.startsWith(':') || !icon.endsWith(':')) return null;
|
||||
final shortcode = normalizeShortcode(icon);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
import 'dart:math' as math;
|
||||
import 'dart:ui' show FlutterView;
|
||||
|
||||
import 'package:camera/camera.dart' as camera;
|
||||
import 'package:flutter/foundation.dart';
|
||||
@@ -19,8 +20,10 @@ import '../../shared/mentions/agent_identity_provider.dart';
|
||||
import '../../shared/relay/relay.dart';
|
||||
import '../../shared/theme/theme.dart';
|
||||
import '../../shared/widgets/avatar_image.dart';
|
||||
import '../../shared/widgets/anchored_popover_menu.dart';
|
||||
import '../../shared/widgets/buzz_loading_indicator.dart';
|
||||
import '../../shared/widgets/keyboard_dismiss_on_drag.dart';
|
||||
import '../../shared/widgets/mobile_tab_footer_backdrop.dart';
|
||||
import '../profile/user_cache_provider.dart';
|
||||
import '../profile/user_profile.dart';
|
||||
import '../../shared/custom_emoji/custom_emoji.dart';
|
||||
@@ -48,6 +51,7 @@ part 'compose_bar/ios_attachment_popover.dart';
|
||||
part 'compose_bar/camera_preview.dart';
|
||||
part 'compose_bar/send_button.dart';
|
||||
part 'compose_bar/layout.dart';
|
||||
part 'compose_bar/dock.dart';
|
||||
|
||||
const _maxConcurrentImageUploads = 3;
|
||||
|
||||
@@ -84,6 +88,7 @@ class ComposeBar extends HookConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final controller = useMemoized(_MarkdownEditingController.new);
|
||||
useListenable(controller);
|
||||
useEffect(() => controller.dispose, [controller]);
|
||||
|
||||
// Restore and persist unsent text as a local draft so the Activity
|
||||
@@ -126,7 +131,13 @@ class ComposeBar extends HookConsumerWidget {
|
||||
return () => controller.removeListener(persistDraft);
|
||||
}, [controller, draftKey, draftIdentity]);
|
||||
final focusNode = useFocusNode();
|
||||
useEffect(
|
||||
() =>
|
||||
() => _dismissComposerKeyboard(focusNode),
|
||||
[focusNode],
|
||||
);
|
||||
final isComposerExpanded = useState(false);
|
||||
final isEmojiPickerOpen = useState(false);
|
||||
final attachmentSurface = useState(_AttachmentSurface.closed);
|
||||
final iosAttachmentPopover = useMemoized(
|
||||
_IOSAttachmentPopoverController.new,
|
||||
@@ -144,6 +155,7 @@ class ComposeBar extends HookConsumerWidget {
|
||||
final clipboardHasImage = useState(false);
|
||||
final hasAttachments = attachments.value.isNotEmpty;
|
||||
final hasPendingUploads = uploadingCount.value > 0;
|
||||
final canSend = controller.text.trim().isNotEmpty || hasAttachments;
|
||||
final customEmoji = ref.watch(customEmojiListProvider);
|
||||
final reducedMotion = MediaQuery.disableAnimationsOf(context);
|
||||
final composerExpansionController = useAnimationController(
|
||||
@@ -155,6 +167,42 @@ class ComposeBar extends HookConsumerWidget {
|
||||
.clamp(0.0, 1.0)
|
||||
.toDouble();
|
||||
|
||||
void collapseComposer() {
|
||||
if (!isComposerExpanded.value) return;
|
||||
showFormatting.value = false;
|
||||
isComposerExpanded.value = false;
|
||||
}
|
||||
|
||||
// A focus loss covers deliberate dismiss gestures. The metrics observer
|
||||
// also catches the system back/swipe dismissal path, where the platform can
|
||||
// hide the keyboard while Flutter keeps the TextField focused.
|
||||
useEffect(() {
|
||||
void collapseWhenUnfocused() {
|
||||
if (!focusNode.hasFocus && !isEmojiPickerOpen.value) {
|
||||
collapseComposer();
|
||||
}
|
||||
}
|
||||
|
||||
focusNode.addListener(collapseWhenUnfocused);
|
||||
return () => focusNode.removeListener(collapseWhenUnfocused);
|
||||
}, [focusNode]);
|
||||
|
||||
final appView = View.of(context);
|
||||
useEffect(() {
|
||||
final observer = _ComposerKeyboardMetricsObserver(
|
||||
view: appView,
|
||||
onKeyboardHidden: () {
|
||||
collapseComposer();
|
||||
// Android Back and iOS dismissal gestures can hide the keyboard
|
||||
// without changing Flutter focus. Clear it as well so reopening the
|
||||
// compact capsule establishes a new text-input connection.
|
||||
focusNode.unfocus();
|
||||
},
|
||||
);
|
||||
WidgetsBinding.instance.addObserver(observer);
|
||||
return () => WidgetsBinding.instance.removeObserver(observer);
|
||||
}, [appView, focusNode]);
|
||||
|
||||
final resolvedHint =
|
||||
hintText ??
|
||||
(channelName.isNotEmpty ? 'Message #$channelName' : 'Message\u2026');
|
||||
@@ -167,8 +215,8 @@ class ComposeBar extends HookConsumerWidget {
|
||||
composerExpansionController.animateWith(
|
||||
SpringSimulation(
|
||||
SpringDescription.withDurationAndBounce(
|
||||
duration: const Duration(milliseconds: 280),
|
||||
bounce: 0.16,
|
||||
duration: const Duration(milliseconds: 220),
|
||||
bounce: 0.08,
|
||||
),
|
||||
composerExpansionController.value,
|
||||
target,
|
||||
@@ -887,64 +935,16 @@ class ComposeBar extends HookConsumerWidget {
|
||||
|
||||
// Suggestions and attachments live in the overlay so showing them cannot
|
||||
// reflow the composer. Both stay anchored just above the capsule.
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: Grid.twelve,
|
||||
right: Grid.twelve,
|
||||
bottom: MediaQuery.viewPaddingOf(context).bottom + Grid.xxs,
|
||||
),
|
||||
child: OverlayPortal.overlayChildLayoutBuilder(
|
||||
final composerWidthFactor = 0.85 + 0.15 * composerExpansionProgress;
|
||||
return _ComposerDockFrame(
|
||||
widthFactor: composerWidthFactor,
|
||||
child: _ComposerOverlayPortal(
|
||||
controller: suggestionOverlayController,
|
||||
overlayChildBuilder: (context, layoutInfo) {
|
||||
final composerOrigin = MatrixUtils.transformPoint(
|
||||
layoutInfo.childPaintTransform,
|
||||
Offset.zero,
|
||||
);
|
||||
return ValueListenableBuilder<_AttachmentSurface>(
|
||||
valueListenable: attachmentSurface,
|
||||
builder: (context, surface, _) {
|
||||
final surfaceDuration = reducedMotion
|
||||
? Duration.zero
|
||||
: Duration(
|
||||
milliseconds:
|
||||
surface == _AttachmentSurface.camera ||
|
||||
surface == _AttachmentSurface.photos
|
||||
? 320
|
||||
: 250,
|
||||
);
|
||||
final expandedSurfaceCoversComposer =
|
||||
surface == _AttachmentSurface.camera ||
|
||||
surface == _AttachmentSurface.photos;
|
||||
final overlayAnchorY =
|
||||
composerOrigin.dy +
|
||||
(expandedSurfaceCoversComposer
|
||||
? layoutInfo.childSize.height + Grid.twelve
|
||||
: 0);
|
||||
return AnimatedPositioned(
|
||||
duration: surfaceDuration,
|
||||
curve:
|
||||
surface == _AttachmentSurface.camera ||
|
||||
surface == _AttachmentSurface.photos
|
||||
? const Cubic(0.34, 1.25, 0.64, 1)
|
||||
: const Cubic(0.22, 1, 0.36, 1),
|
||||
left: composerOrigin.dx,
|
||||
bottom: layoutInfo.overlaySize.height - overlayAnchorY,
|
||||
width: layoutInfo.childSize.width,
|
||||
child: ClipRect(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: Grid.xxs),
|
||||
child: surface == _AttachmentSurface.closed
|
||||
? _SuggestionPanelMotion(
|
||||
duration: surfaceDuration,
|
||||
alignment: Alignment.bottomLeft,
|
||||
child: buildOverlayPanel(surface),
|
||||
)
|
||||
: buildOverlayPanel(surface),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
attachmentSurface: attachmentSurface,
|
||||
reducedMotion: reducedMotion,
|
||||
buildOverlayPanel: buildOverlayPanel,
|
||||
onDismissAttachmentSurface: () {
|
||||
attachmentSurface.value = _AttachmentSurface.closed;
|
||||
},
|
||||
child: _ComposeBarLayout(
|
||||
attachments: attachments.value,
|
||||
@@ -977,12 +977,18 @@ class ComposeBar extends HookConsumerWidget {
|
||||
},
|
||||
onEmoji: () {
|
||||
attachmentSurface.value = _AttachmentSurface.closed;
|
||||
showEmojiPicker(context: context, onSelect: insertEmoji);
|
||||
isEmojiPickerOpen.value = true;
|
||||
_showComposerEmojiPicker(context, insertEmoji, () {
|
||||
if (!context.mounted) return;
|
||||
isEmojiPickerOpen.value = false;
|
||||
focusNode.requestFocus();
|
||||
});
|
||||
},
|
||||
onOpenFormatting: () {
|
||||
attachmentSurface.value = _AttachmentSurface.closed;
|
||||
showFormatting.value = true;
|
||||
},
|
||||
canSend: canSend,
|
||||
hasPendingUploads: hasPendingUploads,
|
||||
isSending: isSending.value,
|
||||
),
|
||||
|
||||
@@ -177,7 +177,7 @@ class _AttachmentSurfacePanel extends HookWidget {
|
||||
final height =
|
||||
menuLayout.height +
|
||||
((expandedHeight - menuLayout.height) * sizeProgress);
|
||||
final baseColor = context.colors.surfaceContainerHighest;
|
||||
final baseColor = appPopoverColor(context);
|
||||
final expandedColor =
|
||||
visibleExpandedSurface == _AttachmentSurface.camera
|
||||
? Colors.black
|
||||
@@ -189,57 +189,51 @@ class _AttachmentSurfacePanel extends HookWidget {
|
||||
child: SizedBox(
|
||||
width: width,
|
||||
height: height,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: Color.lerp(baseColor, expandedColor, sizeProgress),
|
||||
borderRadius: BorderRadius.circular(Radii.dialog),
|
||||
border: Border.all(
|
||||
color: Colors.black.withValues(alpha: 0.04),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(Radii.dialog),
|
||||
child: Material(
|
||||
type: MaterialType.transparency,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.hardEdge,
|
||||
children: [
|
||||
Positioned(
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: _attachmentMenuWidth,
|
||||
height: menuLayout.height,
|
||||
child: IgnorePointer(
|
||||
ignoring: surface != _AttachmentSurface.menu,
|
||||
child: Opacity(
|
||||
opacity: menuOpacity,
|
||||
child: _AttachmentMenu(
|
||||
layout: menuLayout,
|
||||
onCamera: onCamera,
|
||||
onPhotos: onPhotos,
|
||||
onVideo: onVideo,
|
||||
onFiles: onFiles,
|
||||
),
|
||||
),
|
||||
child: Material(
|
||||
key: const ValueKey('attachment-surface-popover'),
|
||||
type: MaterialType.card,
|
||||
color: Color.lerp(baseColor, expandedColor, sizeProgress),
|
||||
surfaceTintColor: Colors.transparent,
|
||||
elevation: appPopoverElevation,
|
||||
shadowColor: appPopoverShadowColor(context),
|
||||
shape: appPopoverShape(context),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.hardEdge,
|
||||
children: [
|
||||
Positioned(
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: _attachmentMenuWidth,
|
||||
height: menuLayout.height,
|
||||
child: IgnorePointer(
|
||||
ignoring: surface != _AttachmentSurface.menu,
|
||||
child: Opacity(
|
||||
opacity: menuOpacity,
|
||||
child: _AttachmentMenu(
|
||||
layout: menuLayout,
|
||||
onCamera: onCamera,
|
||||
onPhotos: onPhotos,
|
||||
onVideo: onVideo,
|
||||
onFiles: onFiles,
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: expandedWidth,
|
||||
height: expandedHeight,
|
||||
child: IgnorePointer(
|
||||
ignoring: !isExpanded,
|
||||
child: Opacity(
|
||||
opacity: expandedOpacity,
|
||||
child: expandedContent,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: expandedWidth,
|
||||
height: expandedHeight,
|
||||
child: IgnorePointer(
|
||||
ignoring: !isExpanded,
|
||||
child: Opacity(
|
||||
opacity: expandedOpacity,
|
||||
child: expandedContent,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -308,7 +302,7 @@ class _AttachmentTrigger extends StatelessWidget {
|
||||
_AttachmentSurface.camera ||
|
||||
_AttachmentSurface.photos => 'Back to attachment options',
|
||||
},
|
||||
onPressed: () => onTap(context),
|
||||
onPressed: () => _runComposerAction(() => onTap(context)),
|
||||
padding: EdgeInsets.zero,
|
||||
visualDensity: VisualDensity.compact,
|
||||
icon: AnimatedRotation(
|
||||
@@ -417,7 +411,7 @@ class _AttachmentMenuItem extends StatelessWidget {
|
||||
child: Tooltip(
|
||||
message: label,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
onTap: () => _runComposerAction(onTap),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: Grid.xxs),
|
||||
child: Row(
|
||||
@@ -617,7 +611,8 @@ class _AttachmentStrip extends StatelessWidget {
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: IconButton(
|
||||
onPressed: () => onRemove(attachment.url),
|
||||
onPressed: () =>
|
||||
_runComposerAction(() => onRemove(attachment.url)),
|
||||
tooltip: 'Remove attachment',
|
||||
visualDensity: VisualDensity.compact,
|
||||
style: IconButton.styleFrom(
|
||||
|
||||
@@ -252,7 +252,7 @@ class _CameraCaptureButton extends StatelessWidget {
|
||||
button: true,
|
||||
label: 'Take photo',
|
||||
child: GestureDetector(
|
||||
onTap: isPressed ? null : onTap,
|
||||
onTap: isPressed ? null : () => _runComposerAction(onTap),
|
||||
child: AnimatedScale(
|
||||
scale: isPressed ? 0.92 : 1,
|
||||
duration: duration,
|
||||
@@ -290,7 +290,7 @@ class _CameraCloseButton extends StatelessWidget {
|
||||
return SizedBox.square(
|
||||
dimension: emphasized ? _cameraBackSize : 36,
|
||||
child: IconButton(
|
||||
onPressed: onTap,
|
||||
onPressed: () => _runComposerAction(onTap),
|
||||
tooltip: 'Back to attachment options',
|
||||
padding: EdgeInsets.zero,
|
||||
style: IconButton.styleFrom(
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
part of '../compose_bar.dart';
|
||||
|
||||
class _ComposerDockFrame extends StatelessWidget {
|
||||
final double widthFactor;
|
||||
final Widget child;
|
||||
|
||||
const _ComposerDockFrame({required this.widthFactor, required this.child});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final backdropHeight = mobileTabFooterBackdropHeight(context);
|
||||
return Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Positioned(
|
||||
key: const ValueKey('composer-footer-gradient'),
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: backdropHeight,
|
||||
child: IgnorePointer(
|
||||
child: MobileTabFooterBackdrop(height: backdropHeight),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: Grid.twelve,
|
||||
right: Grid.twelve,
|
||||
bottom: MediaQuery.viewPaddingOf(context).bottom + Grid.xxs,
|
||||
),
|
||||
child: Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: FractionallySizedBox(
|
||||
key: const ValueKey('composer-width-transition'),
|
||||
widthFactor: widthFactor,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ComposerOverlayPortal extends StatelessWidget {
|
||||
final OverlayPortalController controller;
|
||||
final ValueListenable<_AttachmentSurface> attachmentSurface;
|
||||
final bool reducedMotion;
|
||||
final Widget Function(_AttachmentSurface surface) buildOverlayPanel;
|
||||
final VoidCallback onDismissAttachmentSurface;
|
||||
final Widget child;
|
||||
|
||||
const _ComposerOverlayPortal({
|
||||
required this.controller,
|
||||
required this.attachmentSurface,
|
||||
required this.reducedMotion,
|
||||
required this.buildOverlayPanel,
|
||||
required this.onDismissAttachmentSurface,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return OverlayPortal.overlayChildLayoutBuilder(
|
||||
controller: controller,
|
||||
overlayChildBuilder: (context, layoutInfo) {
|
||||
final composerOrigin = MatrixUtils.transformPoint(
|
||||
layoutInfo.childPaintTransform,
|
||||
Offset.zero,
|
||||
);
|
||||
return ValueListenableBuilder<_AttachmentSurface>(
|
||||
valueListenable: attachmentSurface,
|
||||
builder: (context, surface, _) {
|
||||
final surfaceDuration = reducedMotion
|
||||
? Duration.zero
|
||||
: Duration(
|
||||
milliseconds:
|
||||
surface == _AttachmentSurface.camera ||
|
||||
surface == _AttachmentSurface.photos
|
||||
? 320
|
||||
: 250,
|
||||
);
|
||||
final expandedSurfaceCoversComposer =
|
||||
surface == _AttachmentSurface.camera ||
|
||||
surface == _AttachmentSurface.photos;
|
||||
final surfaceLeft = expandedSurfaceCoversComposer
|
||||
? Grid.twelve
|
||||
: composerOrigin.dx;
|
||||
final surfaceWidth = expandedSurfaceCoversComposer
|
||||
? layoutInfo.overlaySize.width - (Grid.twelve * 2)
|
||||
: layoutInfo.childSize.width;
|
||||
final overlayAnchorY =
|
||||
composerOrigin.dy +
|
||||
(expandedSurfaceCoversComposer
|
||||
? layoutInfo.childSize.height + Grid.twelve
|
||||
: 0);
|
||||
return Stack(
|
||||
children: [
|
||||
if (surface != _AttachmentSurface.closed)
|
||||
Positioned(
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 0,
|
||||
height: composerOrigin.dy,
|
||||
child: ExcludeSemantics(
|
||||
child: GestureDetector(
|
||||
key: const ValueKey('attachment-dismiss-barrier'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onDismissAttachmentSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
AnimatedPositioned(
|
||||
duration: surfaceDuration,
|
||||
curve:
|
||||
surface == _AttachmentSurface.camera ||
|
||||
surface == _AttachmentSurface.photos
|
||||
? const Cubic(0.34, 1.25, 0.64, 1)
|
||||
: const Cubic(0.22, 1, 0.36, 1),
|
||||
left: surfaceLeft,
|
||||
bottom: layoutInfo.overlaySize.height - overlayAnchorY,
|
||||
width: surfaceWidth,
|
||||
child: ClipRect(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: Grid.xxs),
|
||||
child: surface == _AttachmentSurface.closed
|
||||
? _SuggestionPanelMotion(
|
||||
duration: surfaceDuration,
|
||||
alignment: Alignment.bottomLeft,
|
||||
child: buildOverlayPanel(surface),
|
||||
)
|
||||
: buildOverlayPanel(surface),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -58,7 +58,7 @@ class _FormatButton extends StatelessWidget {
|
||||
message: tooltip,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(Radii.sm),
|
||||
onTap: onTap,
|
||||
onTap: () => _runComposerAction(onTap),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(Grid.xxs),
|
||||
child: Icon(icon, size: 18, color: context.colors.primary),
|
||||
@@ -80,7 +80,7 @@ class _ComposeAction extends StatelessWidget {
|
||||
width: 36,
|
||||
height: 36,
|
||||
child: IconButton(
|
||||
onPressed: onTap,
|
||||
onPressed: () => _runComposerAction(onTap),
|
||||
icon: Icon(icon, size: 20, color: context.colors.onSurfaceVariant),
|
||||
padding: EdgeInsets.zero,
|
||||
visualDensity: VisualDensity.compact,
|
||||
|
||||
@@ -1,6 +1,47 @@
|
||||
part of '../compose_bar.dart';
|
||||
|
||||
const _typingThrottleMs = 3000;
|
||||
|
||||
class _ComposerKeyboardMetricsObserver with WidgetsBindingObserver {
|
||||
final FlutterView view;
|
||||
final VoidCallback onKeyboardHidden;
|
||||
bool _wasVisible;
|
||||
|
||||
_ComposerKeyboardMetricsObserver({
|
||||
required this.view,
|
||||
required this.onKeyboardHidden,
|
||||
}) : _wasVisible = view.viewInsets.bottom > 0;
|
||||
|
||||
@override
|
||||
void didChangeMetrics() {
|
||||
final isVisible = view.viewInsets.bottom > 0;
|
||||
if (_wasVisible && !isVisible) onKeyboardHidden();
|
||||
_wasVisible = isVisible;
|
||||
}
|
||||
}
|
||||
|
||||
void _runComposerAction(VoidCallback action) {
|
||||
unawaited(HapticFeedback.selectionClick());
|
||||
action();
|
||||
}
|
||||
|
||||
void _showComposerEmojiPicker(
|
||||
BuildContext context,
|
||||
ValueChanged<String> onSelect,
|
||||
VoidCallback onDismiss,
|
||||
) {
|
||||
showEmojiPicker(
|
||||
context: context,
|
||||
onSelect: (emoji) => _runComposerAction(() => onSelect(emoji)),
|
||||
onDismiss: onDismiss,
|
||||
);
|
||||
}
|
||||
|
||||
void _dismissComposerKeyboard(FocusNode focusNode) {
|
||||
focusNode.unfocus();
|
||||
unawaited(SystemChannels.textInput.invokeMethod<void>('TextInput.hide'));
|
||||
}
|
||||
|
||||
const _pastedImageMimeTypes = <String>[
|
||||
'image/jpeg',
|
||||
'image/jpg',
|
||||
|
||||
@@ -146,7 +146,9 @@ class _IOSInlinePhotoPicker extends HookWidget {
|
||||
),
|
||||
child: IconButton(
|
||||
key: const ValueKey('ios-inline-photo-picker-back'),
|
||||
onPressed: isProcessing.value ? null : onBack,
|
||||
onPressed: isProcessing.value
|
||||
? null
|
||||
: () => _runComposerAction(onBack),
|
||||
tooltip: 'Back to attachment options',
|
||||
icon: const Icon(
|
||||
LucideIcons.chevronLeft,
|
||||
@@ -164,11 +166,12 @@ class _IOSInlinePhotoPicker extends HookWidget {
|
||||
child: FilledButton(
|
||||
key: const ValueKey('ios-inline-photo-picker-select'),
|
||||
onPressed: canSelect
|
||||
? submitSelection
|
||||
? () =>
|
||||
_runComposerAction(() => unawaited(submitSelection()))
|
||||
: selectedCount.value == 0 &&
|
||||
!isPreparingSelection.value &&
|
||||
!isProcessing.value
|
||||
? openAllPhotos
|
||||
? () => _runComposerAction(() => unawaited(openAllPhotos()))
|
||||
: null,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Colors.black.withValues(alpha: 0.76),
|
||||
|
||||
@@ -25,6 +25,7 @@ class _ComposeBarLayout extends StatelessWidget {
|
||||
final VoidCallback onChannel;
|
||||
final VoidCallback onEmoji;
|
||||
final VoidCallback onOpenFormatting;
|
||||
final bool canSend;
|
||||
final bool hasPendingUploads;
|
||||
final bool isSending;
|
||||
|
||||
@@ -53,6 +54,7 @@ class _ComposeBarLayout extends StatelessWidget {
|
||||
required this.onChannel,
|
||||
required this.onEmoji,
|
||||
required this.onOpenFormatting,
|
||||
required this.canSend,
|
||||
required this.hasPendingUploads,
|
||||
required this.isSending,
|
||||
});
|
||||
@@ -63,10 +65,17 @@ class _ComposeBarLayout extends StatelessWidget {
|
||||
}
|
||||
|
||||
Widget _buildBar(BuildContext context) {
|
||||
final trimmedDraft = controller.text.trim();
|
||||
final collapsedText = trimmedDraft.isEmpty
|
||||
? resolvedHint
|
||||
: trimmedDraft.replaceAll(RegExp(r'\s+'), ' ');
|
||||
final composerRadius =
|
||||
Radii.dialog + Grid.quarter * (1 - expansionProgress);
|
||||
return Container(
|
||||
key: const ValueKey('composer-surface'),
|
||||
decoration: BoxDecoration(
|
||||
color: context.colors.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(Radii.dialog),
|
||||
borderRadius: BorderRadius.circular(composerRadius),
|
||||
border: Border.all(
|
||||
color: Colors.black.withValues(alpha: 0.04),
|
||||
width: 1,
|
||||
@@ -142,7 +151,7 @@ class _ComposeBarLayout extends StatelessWidget {
|
||||
label: resolvedHint,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onExpand,
|
||||
onTap: () => _runComposerAction(onExpand),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: Grid.half,
|
||||
@@ -150,9 +159,13 @@ class _ComposeBarLayout extends StatelessWidget {
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
resolvedHint,
|
||||
collapsedText,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: context.textTheme.bodyLarge?.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
color: trimmedDraft.isEmpty
|
||||
? context.colors.onSurfaceVariant
|
||||
: context.colors.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -160,6 +173,12 @@ class _ComposeBarLayout extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: Grid.xxs),
|
||||
_SendButton(
|
||||
isDisabled: !canSend || hasPendingUploads,
|
||||
isSending: isSending,
|
||||
onTap: onSend,
|
||||
),
|
||||
],
|
||||
),
|
||||
ClipRect(
|
||||
@@ -167,7 +186,7 @@ class _ComposeBarLayout extends StatelessWidget {
|
||||
alignment: Alignment.topCenter,
|
||||
heightFactor: expansionValue,
|
||||
child: IgnorePointer(
|
||||
ignoring: expansionValue < 0.98,
|
||||
ignoring: !isExpanded,
|
||||
child: Opacity(
|
||||
opacity: expansionProgress,
|
||||
child: Transform.translate(
|
||||
@@ -225,7 +244,8 @@ class _ComposeBarLayout extends StatelessWidget {
|
||||
),
|
||||
const Spacer(),
|
||||
_SendButton(
|
||||
isDisabled: hasPendingUploads,
|
||||
isDisabled:
|
||||
!canSend || hasPendingUploads,
|
||||
isSending: isSending,
|
||||
onTap: onSend,
|
||||
),
|
||||
|
||||
@@ -136,7 +136,7 @@ class _RecentPhotoGalleryPicker extends HookConsumerWidget {
|
||||
photo: photo,
|
||||
selectionIndex: selectionIndex,
|
||||
reducedMotion: reducedMotion,
|
||||
onTap: () => togglePhoto(photo),
|
||||
onTap: () => _runComposerAction(() => togglePhoto(photo)),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -154,7 +154,9 @@ class _RecentPhotoGalleryPicker extends HookConsumerWidget {
|
||||
children: [
|
||||
IconButton(
|
||||
key: const ValueKey('photo-gallery-back'),
|
||||
onPressed: isResolving.value ? null : onBack,
|
||||
onPressed: isResolving.value
|
||||
? null
|
||||
: () => _runComposerAction(onBack),
|
||||
tooltip: 'Back to attachment options',
|
||||
visualDensity: VisualDensity.compact,
|
||||
icon: const Icon(LucideIcons.arrowLeft, size: 20),
|
||||
@@ -212,7 +214,11 @@ class _RecentPhotoGalleryPicker extends HookConsumerWidget {
|
||||
child: selectedCount == 0
|
||||
? OutlinedButton.icon(
|
||||
key: const ValueKey('photo-gallery-action'),
|
||||
onPressed: isResolving.value ? null : choosePhotos,
|
||||
onPressed: isResolving.value
|
||||
? null
|
||||
: () => _runComposerAction(
|
||||
() => unawaited(choosePhotos()),
|
||||
),
|
||||
icon: isResolving.value
|
||||
? BuzzLoadingIndicator(
|
||||
size: 22,
|
||||
@@ -224,7 +230,11 @@ class _RecentPhotoGalleryPicker extends HookConsumerWidget {
|
||||
)
|
||||
: FilledButton.icon(
|
||||
key: const ValueKey('photo-gallery-action'),
|
||||
onPressed: isResolving.value ? null : choosePhotos,
|
||||
onPressed: isResolving.value
|
||||
? null
|
||||
: () => _runComposerAction(
|
||||
() => unawaited(choosePhotos()),
|
||||
),
|
||||
icon: isResolving.value
|
||||
? const BuzzLoadingIndicator(
|
||||
size: 22,
|
||||
|
||||
@@ -17,7 +17,9 @@ class _SendButton extends StatelessWidget {
|
||||
width: 36,
|
||||
height: 36,
|
||||
child: IconButton(
|
||||
onPressed: (isSending || isDisabled) ? null : onTap,
|
||||
onPressed: (isSending || isDisabled)
|
||||
? null
|
||||
: () => _runComposerAction(onTap),
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: context.colors.primary,
|
||||
disabledBackgroundColor: context.colors.primary.withValues(
|
||||
|
||||
@@ -111,54 +111,55 @@ class _MentionSuggestions extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
constraints: const BoxConstraints(maxHeight: 240),
|
||||
return Material(
|
||||
key: const ValueKey('mention-suggestions-popover'),
|
||||
type: MaterialType.card,
|
||||
color: appPopoverColor(context),
|
||||
surfaceTintColor: Colors.transparent,
|
||||
elevation: appPopoverElevation,
|
||||
shadowColor: appPopoverShadowColor(context),
|
||||
shape: appPopoverShape(context),
|
||||
clipBehavior: Clip.hardEdge,
|
||||
decoration: BoxDecoration(
|
||||
color: context.colors.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(Radii.dialog),
|
||||
border: Border.all(
|
||||
color: Colors.black.withValues(alpha: 0.04),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: ListView.separated(
|
||||
shrinkWrap: true,
|
||||
padding: const EdgeInsets.symmetric(vertical: Grid.xxs),
|
||||
itemCount: suggestions.length,
|
||||
separatorBuilder: (_, _) => const SizedBox.shrink(),
|
||||
itemBuilder: (context, index) {
|
||||
final candidate = suggestions[index];
|
||||
final name = candidate.label;
|
||||
final avatarUrl =
|
||||
candidate.avatarUrl ?? userCache[candidate.pubkey]?.avatarUrl;
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 240),
|
||||
child: ListView.separated(
|
||||
shrinkWrap: true,
|
||||
padding: const EdgeInsets.symmetric(vertical: Grid.xxs),
|
||||
itemCount: suggestions.length,
|
||||
separatorBuilder: (_, _) => const SizedBox.shrink(),
|
||||
itemBuilder: (context, index) {
|
||||
final candidate = suggestions[index];
|
||||
final name = candidate.label;
|
||||
final avatarUrl =
|
||||
candidate.avatarUrl ?? userCache[candidate.pubkey]?.avatarUrl;
|
||||
|
||||
return ListTile(
|
||||
dense: true,
|
||||
visualDensity: VisualDensity.compact,
|
||||
leading: AvatarImage(
|
||||
imageUrl: avatarUrl,
|
||||
radius: 18,
|
||||
backgroundColor: context.colors.primaryContainer,
|
||||
fallback: Text(
|
||||
name[0].toUpperCase(),
|
||||
style: context.textTheme.labelMedium?.copyWith(
|
||||
color: context.colors.onPrimaryContainer,
|
||||
fontWeight: FontWeight.w600,
|
||||
return ListTile(
|
||||
dense: true,
|
||||
visualDensity: VisualDensity.compact,
|
||||
leading: AvatarImage(
|
||||
imageUrl: avatarUrl,
|
||||
radius: 18,
|
||||
backgroundColor: context.colors.primaryContainer,
|
||||
fallback: Text(
|
||||
name[0].toUpperCase(),
|
||||
style: context.textTheme.labelMedium?.copyWith(
|
||||
color: context.colors.onPrimaryContainer,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
title: Text(name, style: context.textTheme.titleSmall),
|
||||
subtitle: _MentionSuggestionInfo.build(
|
||||
context,
|
||||
candidate: candidate,
|
||||
currentPubkey: currentPubkey,
|
||||
isDmChannel: isDmChannel,
|
||||
userCache: userCache,
|
||||
),
|
||||
onTap: () => onSelect(candidate),
|
||||
);
|
||||
},
|
||||
title: Text(name, style: context.textTheme.titleSmall),
|
||||
subtitle: _MentionSuggestionInfo.build(
|
||||
context,
|
||||
candidate: candidate,
|
||||
currentPubkey: currentPubkey,
|
||||
isDmChannel: isDmChannel,
|
||||
userCache: userCache,
|
||||
),
|
||||
onTap: () => _runComposerAction(() => onSelect(candidate)),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -261,40 +262,41 @@ class _ChannelSuggestions extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
constraints: const BoxConstraints(maxHeight: 240),
|
||||
return Material(
|
||||
key: const ValueKey('channel-suggestions-popover'),
|
||||
type: MaterialType.card,
|
||||
color: appPopoverColor(context),
|
||||
surfaceTintColor: Colors.transparent,
|
||||
elevation: appPopoverElevation,
|
||||
shadowColor: appPopoverShadowColor(context),
|
||||
shape: appPopoverShape(context),
|
||||
clipBehavior: Clip.hardEdge,
|
||||
decoration: BoxDecoration(
|
||||
color: context.colors.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(Radii.dialog),
|
||||
border: Border.all(
|
||||
color: Colors.black.withValues(alpha: 0.04),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: ListView.separated(
|
||||
shrinkWrap: true,
|
||||
padding: const EdgeInsets.symmetric(vertical: Grid.xxs),
|
||||
itemCount: suggestions.length,
|
||||
separatorBuilder: (_, _) => const SizedBox.shrink(),
|
||||
itemBuilder: (context, index) {
|
||||
final channel = suggestions[index];
|
||||
return ListTile(
|
||||
dense: true,
|
||||
visualDensity: VisualDensity.compact,
|
||||
horizontalTitleGap: 0,
|
||||
leading: SizedBox.square(
|
||||
dimension: 36,
|
||||
child: Icon(
|
||||
LucideIcons.hash,
|
||||
size: 20,
|
||||
color: context.colors.onSurfaceVariant,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 240),
|
||||
child: ListView.separated(
|
||||
shrinkWrap: true,
|
||||
padding: const EdgeInsets.symmetric(vertical: Grid.xxs),
|
||||
itemCount: suggestions.length,
|
||||
separatorBuilder: (_, _) => const SizedBox.shrink(),
|
||||
itemBuilder: (context, index) {
|
||||
final channel = suggestions[index];
|
||||
return ListTile(
|
||||
dense: true,
|
||||
visualDensity: VisualDensity.compact,
|
||||
horizontalTitleGap: 0,
|
||||
leading: SizedBox.square(
|
||||
dimension: 36,
|
||||
child: Icon(
|
||||
LucideIcons.hash,
|
||||
size: 20,
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
title: Text(channel.name, style: context.textTheme.bodyLarge),
|
||||
onTap: () => onSelect(channel),
|
||||
);
|
||||
},
|
||||
title: Text(channel.name, style: context.textTheme.bodyLarge),
|
||||
onTap: () => _runComposerAction(() => onSelect(channel)),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
|
||||
/// Reports the laid-out height of a floating composer dock.
|
||||
///
|
||||
/// Message timelines use that height as scroll padding while still painting
|
||||
/// beneath the dock, which lets the dock's fade reveal real timeline content.
|
||||
class ComposerDockSizeReporter extends HookWidget {
|
||||
final ValueChanged<double> onHeightChanged;
|
||||
final Widget child;
|
||||
|
||||
const ComposerDockSizeReporter({
|
||||
super.key,
|
||||
required this.onHeightChanged,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final sizeKey = useMemoized(GlobalKey.new);
|
||||
final lastHeight = useRef<double?>(null);
|
||||
|
||||
void reportHeight() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final renderObject = sizeKey.currentContext?.findRenderObject();
|
||||
if (renderObject is! RenderBox || !renderObject.hasSize) return;
|
||||
final height = renderObject.size.height;
|
||||
final previous = lastHeight.value;
|
||||
if (previous != null && (previous - height).abs() < 0.5) return;
|
||||
lastHeight.value = height;
|
||||
onHeightChanged(height);
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() {
|
||||
reportHeight();
|
||||
return null;
|
||||
}, const []);
|
||||
|
||||
return NotificationListener<SizeChangedLayoutNotification>(
|
||||
onNotification: (_) {
|
||||
reportHeight();
|
||||
return true;
|
||||
},
|
||||
child: SizeChangedLayoutNotifier(
|
||||
child: KeyedSubtree(key: sizeKey, child: child),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import '../../shared/custom_emoji/custom_emoji_render.dart';
|
||||
import '../../shared/emoji/emoji_data.dart';
|
||||
import '../../shared/emoji/emoji_data_provider.dart';
|
||||
import '../../shared/emoji/emoji_search.dart';
|
||||
import '../../shared/emoji/native_emoji_glyph.dart';
|
||||
import '../../shared/theme/theme.dart';
|
||||
import 'recent_emoji_provider.dart';
|
||||
|
||||
@@ -30,6 +31,7 @@ const _sheetHeightFactor = 0.62;
|
||||
void showEmojiPicker({
|
||||
required BuildContext context,
|
||||
required void Function(String emoji) onSelect,
|
||||
VoidCallback? onDismiss,
|
||||
}) {
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
@@ -42,7 +44,7 @@ void showEmojiPicker({
|
||||
onSelect(emoji);
|
||||
},
|
||||
),
|
||||
);
|
||||
).whenComplete(onDismiss ?? () {});
|
||||
}
|
||||
|
||||
class EmojiPickerSheet extends HookConsumerWidget {
|
||||
|
||||
@@ -99,10 +99,7 @@ class _EmojiTile extends StatelessWidget {
|
||||
button: true,
|
||||
label: entry.name,
|
||||
child: Center(
|
||||
child: Text(
|
||||
entry.native,
|
||||
style: const TextStyle(fontSize: _emojiGlyphSize),
|
||||
),
|
||||
child: NativeEmojiGlyph(emoji: entry.native, size: _emojiGlyphSize),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -17,6 +17,7 @@ import '../../shared/theme/theme.dart';
|
||||
import '../../shared/custom_emoji/custom_emoji.dart';
|
||||
import '../../shared/custom_emoji/custom_emoji_provider.dart';
|
||||
import '../../shared/custom_emoji/custom_emoji_render.dart';
|
||||
import '../../shared/emoji/native_emoji_glyph.dart';
|
||||
import '../../shared/widgets/sheet_divider.dart';
|
||||
import '../../shared/reminders/remind_me_later_sheet.dart';
|
||||
import '../../shared/reminders/reminder_service.dart';
|
||||
@@ -689,7 +690,7 @@ class _QuickReactionGlyph extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
return Text(value, style: const TextStyle(fontSize: 24));
|
||||
return NativeEmojiGlyph(emoji: value, size: 24);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import '../../shared/widgets/avatar_image.dart';
|
||||
import '../../shared/custom_emoji/custom_emoji_render.dart';
|
||||
import '../../shared/emoji/emoji_burst.dart';
|
||||
import '../../shared/emoji/emoji_data_provider.dart';
|
||||
import '../../shared/emoji/native_emoji_glyph.dart';
|
||||
import '../../shared/emoji/positive_emoji.dart';
|
||||
import '../profile/user_cache_provider.dart';
|
||||
import '../profile/user_profile.dart';
|
||||
@@ -303,7 +304,7 @@ class _ReactionEmoji extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final emojiUrl = reaction.emojiUrl;
|
||||
if (emojiUrl == null || emojiUrl.isEmpty) {
|
||||
return Text(reaction.emoji, style: TextStyle(fontSize: size));
|
||||
return NativeEmojiGlyph(emoji: reaction.emoji, size: size);
|
||||
}
|
||||
final shortcode = reaction.emoji.substring(1, reaction.emoji.length - 1);
|
||||
return CustomEmojiImage(shortcode: shortcode, url: emojiUrl, size: size);
|
||||
|
||||
@@ -20,6 +20,7 @@ import 'channel_typing_indicator.dart';
|
||||
import 'thread_replies_provider.dart';
|
||||
import 'channels_provider.dart';
|
||||
import 'compose_bar.dart';
|
||||
import 'composer_dock_size_reporter.dart';
|
||||
import 'date_formatters.dart';
|
||||
import 'day_divider.dart';
|
||||
import '../profile/user_profile_sheet.dart';
|
||||
@@ -58,6 +59,7 @@ class ThreadDetailPage extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final composerDockHeight = useState(0.0);
|
||||
// Relay thread queries are keyed by the outermost root, even when this
|
||||
// page displays a nested branch. Query that root, then select this head's
|
||||
// direct children from the returned subtree below.
|
||||
@@ -113,11 +115,35 @@ class ThreadDetailPage extends HookConsumerWidget {
|
||||
final itemScrollController = useMemoized(ItemScrollController.new);
|
||||
final itemPositionsListener = useMemoized(ItemPositionsListener.create);
|
||||
final didJumpToInitialMessage = useRef(false);
|
||||
final followsThreadTail = useRef(false);
|
||||
final pendingTailAlignment = useRef<double?>(null);
|
||||
final tailRealignmentQueued = useRef(false);
|
||||
|
||||
// Item 0 is the thread head; reply `i` lives at `i + 1`.
|
||||
const headIndex = 0;
|
||||
int indexForReply(int chronologicalIndex) => chronologicalIndex + 1;
|
||||
|
||||
bool threadTailIsVisible() {
|
||||
final lastIndex = replies.isEmpty
|
||||
? headIndex
|
||||
: indexForReply(replies.length - 1);
|
||||
return itemPositionsListener.itemPositions.value.any(
|
||||
(position) =>
|
||||
position.index == lastIndex && position.itemTrailingEdge <= 1.001,
|
||||
);
|
||||
}
|
||||
|
||||
useEffect(() {
|
||||
void onPositionsChanged() {
|
||||
if (threadTailIsVisible()) followsThreadTail.value = true;
|
||||
}
|
||||
|
||||
itemPositionsListener.itemPositions.addListener(onPositionsChanged);
|
||||
return () => itemPositionsListener.itemPositions.removeListener(
|
||||
onPositionsChanged,
|
||||
);
|
||||
}, [itemPositionsListener, replies.length]);
|
||||
|
||||
useEffect(() {
|
||||
final messageId = initialMessageId;
|
||||
// Wait for the authoritative thread query before consuming the one-shot
|
||||
@@ -134,6 +160,11 @@ class ThreadDetailPage extends HookConsumerWidget {
|
||||
if (targetIndex == null || didJumpToInitialMessage.value) return null;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!context.mounted || !itemScrollController.isAttached) return;
|
||||
// The provisional route snapshot can make the linked reply look like
|
||||
// the tail. This authoritative deep-link jump intentionally leaves
|
||||
// the user at an older item, so it must opt out of follow-tail first.
|
||||
followsThreadTail.value = false;
|
||||
pendingTailAlignment.value = null;
|
||||
itemScrollController.jumpTo(index: targetIndex, alignment: 0.35);
|
||||
didJumpToInitialMessage.value = true;
|
||||
});
|
||||
@@ -227,6 +258,74 @@ class ThreadDetailPage extends HookConsumerWidget {
|
||||
// itself a root message its rootId is null, so fall back to its own id.
|
||||
final effectiveRootId = threadHead.rootId ?? threadHead.id;
|
||||
|
||||
void updateComposerDockHeight(double height) {
|
||||
final previousHeight = composerDockHeight.value;
|
||||
final heightDelta = height - previousHeight;
|
||||
if (heightDelta.abs() < 0.5) return;
|
||||
|
||||
final shouldFollowTail = followsThreadTail.value || threadTailIsVisible();
|
||||
if (shouldFollowTail) followsThreadTail.value = true;
|
||||
composerDockHeight.value = height;
|
||||
if (heightDelta <= 0 || !shouldFollowTail) {
|
||||
pendingTailAlignment.value = null;
|
||||
return;
|
||||
}
|
||||
final lastIndex = replies.isEmpty
|
||||
? headIndex
|
||||
: indexForReply(replies.length - 1);
|
||||
final lastPosition = itemPositionsListener.itemPositions.value
|
||||
.where((position) => position.index == lastIndex)
|
||||
.firstOrNull;
|
||||
if (lastPosition == null) return;
|
||||
final targetAlignment =
|
||||
(pendingTailAlignment.value ?? lastPosition.itemLeadingEdge) -
|
||||
(heightDelta / MediaQuery.sizeOf(context).height);
|
||||
pendingTailAlignment.value = targetAlignment;
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!context.mounted || !itemScrollController.isAttached) return;
|
||||
itemScrollController.jumpTo(
|
||||
index: lastIndex,
|
||||
alignment: targetAlignment,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Composer size changes and keyboard metrics changes are independent:
|
||||
// the dock grows first, then the Scaffold's viewport shrinks once the
|
||||
// keyboard appears. Re-align after that latter layout pass too, but only
|
||||
// while the user was already following the thread tail.
|
||||
void realignThreadTailAfterMetricsChange() {
|
||||
final shouldFollowTail = followsThreadTail.value || threadTailIsVisible();
|
||||
if (!shouldFollowTail || tailRealignmentQueued.value) return;
|
||||
followsThreadTail.value = true;
|
||||
tailRealignmentQueued.value = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
tailRealignmentQueued.value = false;
|
||||
if (!context.mounted ||
|
||||
!itemScrollController.isAttached ||
|
||||
!followsThreadTail.value) {
|
||||
return;
|
||||
}
|
||||
final lastIndex = replies.isEmpty
|
||||
? headIndex
|
||||
: indexForReply(replies.length - 1);
|
||||
itemScrollController.scrollTo(
|
||||
index: lastIndex,
|
||||
duration: const Duration(milliseconds: 220),
|
||||
curve: Curves.easeOutCubic,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() {
|
||||
final observer = _ThreadTailMetricsObserver(
|
||||
onMetricsChanged: realignThreadTailAfterMetricsChange,
|
||||
);
|
||||
WidgetsBinding.instance.addObserver(observer);
|
||||
return () => WidgetsBinding.instance.removeObserver(observer);
|
||||
}, [itemScrollController, replies.length]);
|
||||
|
||||
// Channel names for message content rendering.
|
||||
final channelsAsync = ref.watch(channelsProvider);
|
||||
final channelNamesMap = <String, String>{};
|
||||
@@ -241,176 +340,213 @@ class ThreadDetailPage extends HookConsumerWidget {
|
||||
title: Text('Thread'),
|
||||
titleStyle: channelTitleTextStyle,
|
||||
),
|
||||
body: Column(
|
||||
body: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Expanded(
|
||||
child: KeyboardDismissOnDrag(
|
||||
child: ScrollablePositionedList.builder(
|
||||
key: const ValueKey('thread-message-list'),
|
||||
itemScrollController: itemScrollController,
|
||||
itemPositionsListener: itemPositionsListener,
|
||||
// Top-anchored, head first, replies flowing down — matching
|
||||
// desktop's thread panel. The old reversed list bottom-anchored
|
||||
// the content, which jammed the head against the composer
|
||||
// whenever a thread had only a handful of replies.
|
||||
padding: EdgeInsets.only(
|
||||
left: Grid.gutter,
|
||||
right: Grid.gutter,
|
||||
top: frostedAppBarHeight(context),
|
||||
bottom: Grid.xs,
|
||||
),
|
||||
itemCount: replies.length + 1, // +1 for thread head
|
||||
itemBuilder: (context, index) {
|
||||
if (index == headIndex) {
|
||||
if (liveDeletionHidesHead) {
|
||||
return const Padding(
|
||||
key: ValueKey('thread-message-deleted'),
|
||||
padding: EdgeInsets.only(bottom: Grid.xs),
|
||||
child: Text('This message was deleted'),
|
||||
);
|
||||
}
|
||||
return Padding(
|
||||
key: ValueKey('thread-message-group-${liveHead.id}'),
|
||||
padding: const EdgeInsets.only(bottom: Grid.xs),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
DayDivider(
|
||||
label: formatDayHeading(liveHead.createdAt),
|
||||
),
|
||||
_ThreadMessage(
|
||||
message: liveHead,
|
||||
channelNames: channelNamesMap,
|
||||
channelId: channelId,
|
||||
currentPubkey: currentPubkey,
|
||||
showAuthor: true,
|
||||
isHighlighted: liveHead.id == initialMessageId,
|
||||
allMessages: allMsgs,
|
||||
isMember: isMember,
|
||||
isArchived: isArchived,
|
||||
isThreadHead: true,
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: Grid.xxs,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'${replies.length} ${replies.length == 1 ? 'reply' : 'replies'}',
|
||||
style: context.textTheme.labelMedium
|
||||
?.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: Grid.xxs),
|
||||
Expanded(
|
||||
child: Divider(
|
||||
color: context.colors.outlineVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Chronological list: index 1 = oldest reply.
|
||||
final chronIdx = index - 1;
|
||||
final reply = replies[chronIdx];
|
||||
final prevReply = chronIdx > 0 ? replies[chronIdx - 1] : null;
|
||||
final previousMessage = prevReply ?? liveHead;
|
||||
final showDayDivider = !isSameDay(
|
||||
previousMessage.createdAt,
|
||||
reply.createdAt,
|
||||
);
|
||||
final showAuthor =
|
||||
prevReply == null ||
|
||||
showDayDivider ||
|
||||
prevReply.pubkey.toLowerCase() !=
|
||||
reply.pubkey.toLowerCase() ||
|
||||
(reply.createdAt - prevReply.createdAt) > 300;
|
||||
|
||||
// Check if this reply itself has children (nested thread).
|
||||
final nestedChildren = childrenByParent[reply.id];
|
||||
final nestedSummary =
|
||||
nestedChildren != null && nestedChildren.isNotEmpty
|
||||
? _buildNestedSummary(reply.id, nestedChildren)
|
||||
: null;
|
||||
|
||||
return Padding(
|
||||
key: ValueKey('thread-message-group-${reply.id}'),
|
||||
// Tail spacing comes from the list's own bottom padding now
|
||||
// that the list runs top-down; the reversed list used to
|
||||
// need it here because item 0 sat against the composer.
|
||||
padding: EdgeInsets.zero,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (showDayDivider)
|
||||
DayDivider(label: formatDayHeading(reply.createdAt)),
|
||||
_ThreadMessage(
|
||||
message: reply,
|
||||
channelNames: channelNamesMap,
|
||||
channelId: channelId,
|
||||
currentPubkey: currentPubkey,
|
||||
showAuthor: showAuthor,
|
||||
isHighlighted: reply.id == initialMessageId,
|
||||
allMessages: allMsgs,
|
||||
isMember: isMember,
|
||||
isArchived: isArchived,
|
||||
),
|
||||
if (nestedSummary != null)
|
||||
_NestedThreadSummaryRow(
|
||||
summary: nestedSummary,
|
||||
replyMessage: reply,
|
||||
allMessages: allMsgs,
|
||||
channelId: channelId,
|
||||
currentPubkey: currentPubkey,
|
||||
isMember: isMember,
|
||||
isArchived: isArchived,
|
||||
),
|
||||
],
|
||||
Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: KeyboardDismissOnDrag(
|
||||
onUserScrollStart: () {
|
||||
followsThreadTail.value = false;
|
||||
pendingTailAlignment.value = null;
|
||||
},
|
||||
child: ScrollablePositionedList.builder(
|
||||
key: const ValueKey('thread-message-list'),
|
||||
itemScrollController: itemScrollController,
|
||||
itemPositionsListener: itemPositionsListener,
|
||||
// Top-anchored, head first, replies flowing down — matching
|
||||
// desktop's thread panel. The old reversed list bottom-anchored
|
||||
// the content, which jammed the head against the composer
|
||||
// whenever a thread had only a handful of replies.
|
||||
padding: EdgeInsets.only(
|
||||
left: Grid.gutter,
|
||||
right: Grid.gutter,
|
||||
top: frostedAppBarHeight(context),
|
||||
bottom: Grid.xs + composerDockHeight.value,
|
||||
),
|
||||
);
|
||||
},
|
||||
itemCount: replies.length + 1, // +1 for thread head
|
||||
itemBuilder: (context, index) {
|
||||
if (index == headIndex) {
|
||||
if (liveDeletionHidesHead) {
|
||||
return const Padding(
|
||||
key: ValueKey('thread-message-deleted'),
|
||||
padding: EdgeInsets.only(bottom: Grid.xs),
|
||||
child: Text('This message was deleted'),
|
||||
);
|
||||
}
|
||||
return Padding(
|
||||
key: ValueKey('thread-message-group-${liveHead.id}'),
|
||||
padding: const EdgeInsets.only(bottom: Grid.xs),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
DayDivider(
|
||||
label: formatDayHeading(liveHead.createdAt),
|
||||
),
|
||||
_ThreadMessage(
|
||||
message: liveHead,
|
||||
channelNames: channelNamesMap,
|
||||
channelId: channelId,
|
||||
currentPubkey: currentPubkey,
|
||||
showAuthor: true,
|
||||
isHighlighted: liveHead.id == initialMessageId,
|
||||
allMessages: allMsgs,
|
||||
isMember: isMember,
|
||||
isArchived: isArchived,
|
||||
isThreadHead: true,
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: Grid.xxs,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'${replies.length} ${replies.length == 1 ? 'reply' : 'replies'}',
|
||||
style: context.textTheme.labelMedium
|
||||
?.copyWith(
|
||||
color:
|
||||
context.colors.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: Grid.xxs),
|
||||
Expanded(
|
||||
child: Divider(
|
||||
color: context.colors.outlineVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Chronological list: index 1 = oldest reply.
|
||||
final chronIdx = index - 1;
|
||||
final reply = replies[chronIdx];
|
||||
final prevReply = chronIdx > 0
|
||||
? replies[chronIdx - 1]
|
||||
: null;
|
||||
final previousMessage = prevReply ?? liveHead;
|
||||
final showDayDivider = !isSameDay(
|
||||
previousMessage.createdAt,
|
||||
reply.createdAt,
|
||||
);
|
||||
final showAuthor =
|
||||
prevReply == null ||
|
||||
showDayDivider ||
|
||||
prevReply.pubkey.toLowerCase() !=
|
||||
reply.pubkey.toLowerCase() ||
|
||||
(reply.createdAt - prevReply.createdAt) > 300;
|
||||
|
||||
// Check if this reply itself has children (nested thread).
|
||||
final nestedChildren = childrenByParent[reply.id];
|
||||
final nestedSummary =
|
||||
nestedChildren != null && nestedChildren.isNotEmpty
|
||||
? _buildNestedSummary(reply.id, nestedChildren)
|
||||
: null;
|
||||
|
||||
return Padding(
|
||||
key: ValueKey('thread-message-group-${reply.id}'),
|
||||
// Tail spacing comes from the list's own bottom padding now
|
||||
// that the list runs top-down; the reversed list used to
|
||||
// need it here because item 0 sat against the composer.
|
||||
padding: EdgeInsets.zero,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (showDayDivider)
|
||||
DayDivider(
|
||||
label: formatDayHeading(reply.createdAt),
|
||||
),
|
||||
_ThreadMessage(
|
||||
message: reply,
|
||||
channelNames: channelNamesMap,
|
||||
channelId: channelId,
|
||||
currentPubkey: currentPubkey,
|
||||
showAuthor: showAuthor,
|
||||
isHighlighted: reply.id == initialMessageId,
|
||||
allMessages: allMsgs,
|
||||
isMember: isMember,
|
||||
isArchived: isArchived,
|
||||
),
|
||||
if (nestedSummary != null)
|
||||
_NestedThreadSummaryRow(
|
||||
summary: nestedSummary,
|
||||
replyMessage: reply,
|
||||
allMessages: allMsgs,
|
||||
channelId: channelId,
|
||||
currentPubkey: currentPubkey,
|
||||
isMember: isMember,
|
||||
isArchived: isArchived,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
AnimatedSize(
|
||||
duration: MediaQuery.disableAnimationsOf(context)
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 180),
|
||||
curve: Curves.easeOutCubic,
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: threadTyping.isEmpty
|
||||
? const SizedBox.shrink()
|
||||
: ChannelTypingIndicator(entries: threadTyping),
|
||||
if (!isMember || isArchived)
|
||||
AnimatedSize(
|
||||
duration: MediaQuery.disableAnimationsOf(context)
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 180),
|
||||
curve: Curves.easeOutCubic,
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: threadTyping.isEmpty
|
||||
? const SizedBox.shrink()
|
||||
: ChannelTypingIndicator(entries: threadTyping),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (isMember && !isArchived)
|
||||
ComposeBar(
|
||||
channelId: channelId,
|
||||
hintText: 'Reply in thread\u2026',
|
||||
threadHeadId: threadHead.id,
|
||||
rootId: effectiveRootId,
|
||||
onSend:
|
||||
(
|
||||
content,
|
||||
mentionPubkeys, {
|
||||
mediaTags = const <List<String>>[],
|
||||
}) => ref
|
||||
.read(sendMessageProvider)
|
||||
.call(
|
||||
channelId: channelId,
|
||||
content: content,
|
||||
mentionPubkeys: mentionPubkeys,
|
||||
parentEventId: threadHead.id,
|
||||
rootEventId: effectiveRootId,
|
||||
mediaTags: mediaTags,
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: ComposerDockSizeReporter(
|
||||
key: const ValueKey('thread-composer-dock'),
|
||||
onHeightChanged: updateComposerDockHeight,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
AnimatedSize(
|
||||
duration: MediaQuery.disableAnimationsOf(context)
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 180),
|
||||
curve: Curves.easeOutCubic,
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: threadTyping.isEmpty
|
||||
? const SizedBox.shrink()
|
||||
: ChannelTypingIndicator(entries: threadTyping),
|
||||
),
|
||||
ComposeBar(
|
||||
channelId: channelId,
|
||||
hintText: 'Reply in thread\u2026',
|
||||
threadHeadId: threadHead.id,
|
||||
rootId: effectiveRootId,
|
||||
onSend:
|
||||
(
|
||||
content,
|
||||
mentionPubkeys, {
|
||||
mediaTags = const <List<String>>[],
|
||||
}) => ref
|
||||
.read(sendMessageProvider)
|
||||
.call(
|
||||
channelId: channelId,
|
||||
content: content,
|
||||
mentionPubkeys: mentionPubkeys,
|
||||
parentEventId: threadHead.id,
|
||||
rootEventId: effectiveRootId,
|
||||
mediaTags: mediaTags,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -566,6 +702,15 @@ class _NestedThreadSummaryRow extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _ThreadTailMetricsObserver with WidgetsBindingObserver {
|
||||
final VoidCallback onMetricsChanged;
|
||||
|
||||
_ThreadTailMetricsObserver({required this.onMetricsChanged});
|
||||
|
||||
@override
|
||||
void didChangeMetrics() => onMetricsChanged();
|
||||
}
|
||||
|
||||
class _ThreadMessage extends ConsumerWidget {
|
||||
final TimelineMessage message;
|
||||
final Map<String, String> channelNames;
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A standalone system emoji whose visual centre matches its surrounding UI.
|
||||
///
|
||||
/// Apple's emoji glyphs sit slightly low inside Flutter's text box. Keep the
|
||||
/// layout box unchanged and lift only the painted glyph on iOS; Android's
|
||||
/// system emoji metrics are already visually centred.
|
||||
class NativeEmojiGlyph extends StatelessWidget {
|
||||
final String emoji;
|
||||
final double size;
|
||||
|
||||
const NativeEmojiGlyph({super.key, required this.emoji, required this.size});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final glyph = Text(emoji, style: TextStyle(fontSize: size));
|
||||
if (defaultTargetPlatform != TargetPlatform.iOS) return glyph;
|
||||
|
||||
return Transform.translate(offset: const Offset(0, -1), child: glyph);
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ class Radii {
|
||||
static const double md = 8.0;
|
||||
static const double sm = 6.0;
|
||||
static const double card = 12.0; // grouped settings cards
|
||||
static const double popover = 20.0;
|
||||
static const double dialog = 24.0; // desktop uses rounded-3xl for dialogs
|
||||
|
||||
/// Fully rounds pills, circles, and other capsule shapes.
|
||||
@@ -277,13 +278,22 @@ class AppTheme {
|
||||
labelPadding: EdgeInsets.zero,
|
||||
),
|
||||
|
||||
// Popups/menus: desktop uses rounded-md (8px)
|
||||
// Popups/menus share the elevated 20px mobile popover treatment.
|
||||
popupMenuTheme: PopupMenuThemeData(
|
||||
color: scheme.surface,
|
||||
elevation: 4,
|
||||
color: scheme.surface.withValues(alpha: 0.98),
|
||||
elevation: 8,
|
||||
shadowColor: scheme.shadow.withValues(alpha: 0.18),
|
||||
surfaceTintColor: Colors.transparent,
|
||||
textStyle: textTheme.labelLarge?.copyWith(color: scheme.onSurface),
|
||||
labelTextStyle: WidgetStatePropertyAll(
|
||||
textTheme.labelLarge?.copyWith(color: scheme.onSurface),
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(Radii.md),
|
||||
side: BorderSide(color: scheme.outline),
|
||||
borderRadius: BorderRadius.circular(Radii.popover),
|
||||
side: BorderSide(
|
||||
color: Colors.black.withValues(alpha: 0.04),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
@@ -88,8 +88,14 @@ const contentListBodyTextStyle = TextStyle(
|
||||
/// Timestamps in compact content lists.
|
||||
const contentListTimestampTextStyle = messageMetadataTextStyle;
|
||||
|
||||
/// Filter chip labels use the compact 15sp type ramp.
|
||||
const filterChipTextStyle = messageMetadataTextStyle;
|
||||
/// Filter chip labels use a tighter 15sp Inter treatment.
|
||||
const filterChipTextStyle = TextStyle(
|
||||
fontFamily: _fontFamily,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1,
|
||||
letterSpacing: 0,
|
||||
);
|
||||
|
||||
/// Search fields use the primary 15sp body treatment.
|
||||
const searchInputTextStyle = messageBodyTextStyle;
|
||||
|
||||
@@ -9,6 +9,24 @@ const _popoverEnterDuration = Duration(milliseconds: 150);
|
||||
const _popoverExitDuration = Duration(milliseconds: 110);
|
||||
const _popoverStartScale = 0.96;
|
||||
|
||||
/// Elevation shared by anchored menus and composer popover surfaces.
|
||||
const appPopoverElevation = 8.0;
|
||||
|
||||
/// Returns the translucent surface color shared by app popovers.
|
||||
Color appPopoverColor(BuildContext context) =>
|
||||
context.colors.surface.withValues(alpha: 0.98);
|
||||
|
||||
/// Returns the shadow color shared by app popovers.
|
||||
Color appPopoverShadowColor(BuildContext context) =>
|
||||
context.colors.shadow.withValues(alpha: 0.18);
|
||||
|
||||
/// Returns the 20px shape and composer-matching hairline shared by popovers.
|
||||
RoundedRectangleBorder appPopoverShape(BuildContext context) =>
|
||||
RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(Radii.popover),
|
||||
side: BorderSide(color: Colors.black.withValues(alpha: 0.04), width: 1),
|
||||
);
|
||||
|
||||
/// The horizontal edge a popover aligns to on its triggering control.
|
||||
enum AnchoredPopoverAlignment {
|
||||
/// Aligns the popover's leading edge with the trigger's leading edge.
|
||||
@@ -25,10 +43,10 @@ Future<T?> showAnchoredPopover<T>({
|
||||
required List<PopupMenuEntry<T>> items,
|
||||
required double width,
|
||||
required AnchoredPopoverAlignment alignment,
|
||||
required Color color,
|
||||
required ShapeBorder shape,
|
||||
required double elevation,
|
||||
required Color shadowColor,
|
||||
Color? color,
|
||||
ShapeBorder? shape,
|
||||
double elevation = appPopoverElevation,
|
||||
Color? shadowColor,
|
||||
Offset offset = Offset.zero,
|
||||
EdgeInsetsGeometry menuPadding = EdgeInsets.zero,
|
||||
Clip clipBehavior = Clip.antiAlias,
|
||||
@@ -56,10 +74,10 @@ Future<T?> showAnchoredPopover<T>({
|
||||
width: width,
|
||||
alignment: alignment,
|
||||
offset: offset,
|
||||
color: color,
|
||||
shape: shape,
|
||||
color: color ?? appPopoverColor(context),
|
||||
shape: shape ?? appPopoverShape(context),
|
||||
elevation: elevation,
|
||||
shadowColor: shadowColor,
|
||||
shadowColor: shadowColor ?? appPopoverShadowColor(context),
|
||||
menuPadding: menuPadding,
|
||||
clipBehavior: clipBehavior,
|
||||
surfaceKey: surfaceKey,
|
||||
|
||||
@@ -129,15 +129,23 @@ class FilterChipBar<T> extends StatelessWidget {
|
||||
textAlign: fillWidth ? TextAlign.center : TextAlign.start,
|
||||
style: labelStyle,
|
||||
);
|
||||
final centeredLabel = Align(
|
||||
alignment: Alignment.center,
|
||||
widthFactor: fillWidth ? null : 1,
|
||||
heightFactor: 1,
|
||||
child: label,
|
||||
);
|
||||
final chip = FilterChip(
|
||||
selected: isSelected,
|
||||
showCheckmark: false,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(Radii.lg),
|
||||
),
|
||||
side: BorderSide.none,
|
||||
label: fillWidth
|
||||
? SizedBox(
|
||||
width: double.infinity,
|
||||
child: Center(child: label),
|
||||
)
|
||||
: label,
|
||||
? SizedBox(width: double.infinity, child: centeredLabel)
|
||||
: centeredLabel,
|
||||
labelPadding: EdgeInsets.zero,
|
||||
onSelected: (_) => onSelected(item.id),
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: fillWidth ? Grid.quarter : Grid.twelve,
|
||||
|
||||
@@ -27,8 +27,13 @@ const keyboardDismissDragThreshold = 48.0;
|
||||
/// `WindowInsetsAnimationController`).
|
||||
class KeyboardDismissOnDrag extends HookWidget {
|
||||
final Widget child;
|
||||
final VoidCallback? onUserScrollStart;
|
||||
|
||||
const KeyboardDismissOnDrag({super.key, required this.child});
|
||||
const KeyboardDismissOnDrag({
|
||||
super.key,
|
||||
this.onUserScrollStart,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -37,8 +42,12 @@ class KeyboardDismissOnDrag extends HookWidget {
|
||||
final downwardTravel = useRef(0.0);
|
||||
|
||||
bool handle(ScrollNotification notification) {
|
||||
if (notification is ScrollStartNotification ||
|
||||
notification is ScrollEndNotification) {
|
||||
if (notification is ScrollStartNotification) {
|
||||
if (notification.dragDetails != null) onUserScrollStart?.call();
|
||||
downwardTravel.value = 0;
|
||||
return false;
|
||||
}
|
||||
if (notification is ScrollEndNotification) {
|
||||
downwardTravel.value = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,27 @@ double mobileTabFooterBackdropHeight(BuildContext context) =>
|
||||
Grid.xl +
|
||||
Grid.gutter;
|
||||
|
||||
/// Builds the shared transparent-to-surface footer fade.
|
||||
///
|
||||
/// Kept separate from [MobileTabFooterBackdrop] so floating controls such as
|
||||
/// the channel composer can paint the exact same fade behind their own content.
|
||||
LinearGradient mobileTabFooterBackdropGradient(
|
||||
BuildContext context, {
|
||||
List<double> stops = const [0, 0.5, 1],
|
||||
List<double> opacities = const [0, 0.75, 1],
|
||||
}) {
|
||||
assert(stops.length == opacities.length);
|
||||
final surface = context.colors.surface;
|
||||
return LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
stops: stops,
|
||||
colors: [
|
||||
for (final opacity in opacities) surface.withValues(alpha: opacity),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Shared fade behind the floating mobile tab bar.
|
||||
class MobileTabFooterBackdrop extends StatelessWidget {
|
||||
/// Vertical extent of the backdrop in logical pixels.
|
||||
@@ -39,20 +60,15 @@ class MobileTabFooterBackdrop extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final surface = context.colors.surface;
|
||||
return SizedBox(
|
||||
height: height,
|
||||
width: double.infinity,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
gradient: mobileTabFooterBackdropGradient(
|
||||
context,
|
||||
stops: stops,
|
||||
colors: [
|
||||
for (final opacity in opacities)
|
||||
surface.withValues(alpha: opacity),
|
||||
],
|
||||
opacities: opacities,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -13,6 +13,7 @@ import 'package:buzz/features/channels/read_state/read_state_provider.dart';
|
||||
import 'package:buzz/features/profile/user_cache_provider.dart';
|
||||
import 'package:buzz/features/profile/user_profile.dart';
|
||||
import 'package:buzz/shared/theme/theme.dart';
|
||||
import 'package:buzz/shared/widgets/anchored_popover_menu.dart';
|
||||
import 'package:buzz/shared/widgets/frosted_app_bar.dart';
|
||||
import 'package:buzz/shared/widgets/avatar_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -111,6 +112,7 @@ void main() {
|
||||
Map<String, int> readContexts = const {},
|
||||
List<Channel>? channels,
|
||||
TextScaler? textScaler,
|
||||
EdgeInsets mediaPadding = EdgeInsets.zero,
|
||||
}) async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
@@ -133,12 +135,12 @@ void main() {
|
||||
],
|
||||
child: MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
builder: textScaler == null
|
||||
? null
|
||||
: (context, child) => MediaQuery(
|
||||
data: MediaQuery.of(context).copyWith(textScaler: textScaler),
|
||||
child: child!,
|
||||
),
|
||||
builder: (context, child) => MediaQuery(
|
||||
data: MediaQuery.of(
|
||||
context,
|
||||
).copyWith(textScaler: textScaler, padding: mediaPadding),
|
||||
child: child!,
|
||||
),
|
||||
home: const ActivityPage(),
|
||||
),
|
||||
);
|
||||
@@ -182,16 +184,22 @@ void main() {
|
||||
expect(find.byTooltip('Back'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('keeps bottom clearance for the floating tab bar', (
|
||||
testWidgets('keeps footer clearance inside the scrollable content', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(await buildTestable());
|
||||
await tester.pumpWidget(
|
||||
await buildTestable(mediaPadding: const EdgeInsets.only(bottom: 88)),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final safeAreas = tester.widgetList<SafeArea>(find.byType(SafeArea));
|
||||
expect(safeAreas, hasLength(1));
|
||||
expect(safeAreas.single.top, isFalse);
|
||||
expect(safeAreas.single.bottom, isTrue);
|
||||
final safeArea = tester.widget<SafeArea>(
|
||||
find.byKey(const ValueKey('activity-content-safe-area')),
|
||||
);
|
||||
expect(safeArea.top, isFalse);
|
||||
expect(safeArea.bottom, isFalse);
|
||||
|
||||
final list = tester.widget<ListView>(find.byType(ListView));
|
||||
expect(list.padding, const EdgeInsets.fromLTRB(0, Grid.xxs, 0, 96));
|
||||
});
|
||||
|
||||
testWidgets('shows error view with retry button', (tester) async {
|
||||
@@ -253,7 +261,13 @@ void main() {
|
||||
|
||||
final material = tester.widget<Material>(surface);
|
||||
final shape = material.shape! as RoundedRectangleBorder;
|
||||
expect(shape.borderRadius, BorderRadius.circular(Radii.card));
|
||||
expect(shape.borderRadius, BorderRadius.circular(Radii.popover));
|
||||
expect(shape.side.color, Colors.black.withValues(alpha: 0.04));
|
||||
expect(material.elevation, appPopoverElevation);
|
||||
expect(
|
||||
material.shadowColor,
|
||||
appPopoverShadowColor(tester.element(surface)),
|
||||
);
|
||||
expect(material.surfaceTintColor, Colors.transparent);
|
||||
expect(material.clipBehavior, Clip.antiAlias);
|
||||
|
||||
@@ -275,7 +289,11 @@ void main() {
|
||||
final optionsSurface = find.byKey(
|
||||
const ValueKey('activity-options-popover'),
|
||||
);
|
||||
final optionsMaterial = tester.widget<Material>(optionsSurface);
|
||||
final optionsShape = optionsMaterial.shape! as RoundedRectangleBorder;
|
||||
expect(tester.getSize(optionsSurface).width, 216);
|
||||
expect(optionsShape.borderRadius, BorderRadius.circular(Radii.popover));
|
||||
expect(optionsMaterial.elevation, appPopoverElevation);
|
||||
expect(
|
||||
tester
|
||||
.widget<ScaleTransition>(
|
||||
|
||||
@@ -85,6 +85,25 @@ NostrEvent _systemMsg({
|
||||
sig: '',
|
||||
);
|
||||
|
||||
NostrEvent _huddleMsg({
|
||||
required String id,
|
||||
required int kind,
|
||||
String pubkey = 'alice',
|
||||
int createdAt = 1000,
|
||||
}) => NostrEvent(
|
||||
id: id,
|
||||
pubkey: pubkey,
|
||||
createdAt: createdAt,
|
||||
kind: kind,
|
||||
tags: [
|
||||
['h', _channelId],
|
||||
],
|
||||
content: jsonEncode({
|
||||
'ephemeral_channel_id': '8d764100-fd8f-44cf-9c98-6d8fbd739b8c',
|
||||
}),
|
||||
sig: '',
|
||||
);
|
||||
|
||||
NostrEvent _reaction({
|
||||
required String id,
|
||||
required String targetId,
|
||||
@@ -158,6 +177,7 @@ Widget _buildTestable({
|
||||
Map<String, List<NostrEvent>> threadReplies = const {},
|
||||
Map<String, Future<List<NostrEvent>>> pendingThreadReplies = const {},
|
||||
TextScaler textScaler = TextScaler.noScaling,
|
||||
bool disableAnimations = false,
|
||||
RelaySessionNotifier? relaySessionNotifier,
|
||||
}) {
|
||||
final resolvedChannel = channel ?? _testChannel;
|
||||
@@ -216,7 +236,10 @@ Widget _buildTestable({
|
||||
child: MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
builder: (context, child) => MediaQuery(
|
||||
data: MediaQuery.of(context).copyWith(textScaler: textScaler),
|
||||
data: MediaQuery.of(context).copyWith(
|
||||
textScaler: textScaler,
|
||||
disableAnimations: disableAnimations,
|
||||
),
|
||||
child: child!,
|
||||
),
|
||||
navigatorObservers: navigatorObservers,
|
||||
@@ -729,6 +752,53 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('clears the composer inset when membership is revoked', (
|
||||
tester,
|
||||
) async {
|
||||
final channelsNotifier = _FakeChannelsNotifier([_testChannel]);
|
||||
await tester.pumpWidget(
|
||||
_buildTestable(
|
||||
messages: [
|
||||
_textMsg(
|
||||
id: 'msg1',
|
||||
pubkey: 'alice',
|
||||
content: 'Hello',
|
||||
createdAt: 1000,
|
||||
),
|
||||
],
|
||||
channelsNotifier: channelsNotifier,
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final messageListFinder = find.byKey(
|
||||
const ValueKey('channel-message-list'),
|
||||
);
|
||||
expect(
|
||||
tester
|
||||
.widget<ScrollablePositionedList>(messageListFinder)
|
||||
.padding!
|
||||
.bottom,
|
||||
greaterThan(0),
|
||||
);
|
||||
expect(
|
||||
find.byKey(const ValueKey('channel-composer-dock')),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
channelsNotifier.setChannels([_testChannel.copyWith(isMember: false)]);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(const ValueKey('channel-composer-dock')), findsNothing);
|
||||
expect(
|
||||
tester
|
||||
.widget<ScrollablePositionedList>(messageListFinder)
|
||||
.padding!
|
||||
.bottom,
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('updates detail page state after joining a channel', (
|
||||
tester,
|
||||
) async {
|
||||
@@ -886,7 +956,15 @@ void main() {
|
||||
final messageList = tester.widget<ScrollablePositionedList>(
|
||||
find.byKey(const ValueKey('channel-message-list')),
|
||||
);
|
||||
expect(messageList.padding!.bottom, 0);
|
||||
final composerDock = find.byKey(const ValueKey('channel-composer-dock'));
|
||||
final composerDockHeight = tester.getSize(composerDock).height;
|
||||
expect(messageList.padding!.bottom, composerDockHeight);
|
||||
expect(
|
||||
tester
|
||||
.getBottomLeft(find.byKey(const ValueKey('channel-message-list')))
|
||||
.dy,
|
||||
greaterThan(tester.getTopLeft(composerDock).dy),
|
||||
);
|
||||
final newestMessageGroup = tester.widget<Padding>(
|
||||
find.byKey(const ValueKey('channel-message-group-msg2')),
|
||||
);
|
||||
@@ -1115,6 +1193,26 @@ void main() {
|
||||
find.byKey(const ValueKey('channel-jump-to-latest')),
|
||||
findsOneWidget,
|
||||
);
|
||||
final latestSurface = tester.widget<Container>(
|
||||
find.byKey(const ValueKey('channel-jump-to-latest-surface')),
|
||||
);
|
||||
final latestDecoration = latestSurface.decoration! as BoxDecoration;
|
||||
expect(latestDecoration.borderRadius, BorderRadius.circular(Radii.full));
|
||||
expect(
|
||||
latestDecoration.color,
|
||||
AppTheme.light().colorScheme.surface.withValues(alpha: 0.5),
|
||||
);
|
||||
expect(
|
||||
(latestDecoration.border! as Border).top.color,
|
||||
Colors.black.withValues(alpha: 0.04),
|
||||
);
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byKey(const ValueKey('channel-jump-to-latest')),
|
||||
matching: find.byType(BackdropFilter),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
messagesNotifier.setMessages([
|
||||
...initialMessages,
|
||||
@@ -1446,6 +1544,31 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('renders a huddle event like a regular message row', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
_buildTestable(
|
||||
messages: [_huddleMsg(id: 'huddle-1', kind: EventKind.huddleStarted)],
|
||||
users: {
|
||||
'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'),
|
||||
},
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Alice'), findsOneWidget);
|
||||
expect(findRichText('started a huddle'), findsOneWidget);
|
||||
expect(
|
||||
tester.getSize(find.byType(CircleAvatar)),
|
||||
const Size.square(messageAvatarSize),
|
||||
);
|
||||
expect(
|
||||
find.byKey(const ValueKey('system-message-timestamp-alice')),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('renders member_joined (self-join) system event', (
|
||||
tester,
|
||||
) async {
|
||||
@@ -2011,7 +2134,8 @@ void main() {
|
||||
},
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
|
||||
expect(find.text('Alice is typing…'), findsOneWidget);
|
||||
|
||||
@@ -2030,7 +2154,15 @@ void main() {
|
||||
expect(decoration.border, isA<Border>());
|
||||
expect(
|
||||
tester.widget<Text>(find.text('Alice is typing…')).style?.color,
|
||||
AppTheme.light().colorScheme.primary,
|
||||
AppTheme.light().colorScheme.onSurfaceVariant,
|
||||
);
|
||||
expect(
|
||||
tester.widget<Text>(find.text('Alice is typing…')).style?.fontStyle,
|
||||
isNot(FontStyle.italic),
|
||||
);
|
||||
expect(
|
||||
find.byKey(const ValueKey('channel-typing-shimmer')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(tester.widget<SmallAvatar>(find.byType(SmallAvatar)).size, 24);
|
||||
});
|
||||
@@ -2055,7 +2187,8 @@ void main() {
|
||||
},
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
|
||||
expect(find.text('Alice and Bob are typing…'), findsOneWidget);
|
||||
});
|
||||
@@ -2085,10 +2218,39 @@ void main() {
|
||||
},
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
|
||||
expect(find.text('Alice and 2 others are typing…'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('keeps typing text static when motion is reduced', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
_buildTestable(
|
||||
messages: [],
|
||||
typing: [
|
||||
TypingEntry(
|
||||
pubkey: 'alice',
|
||||
expiresAtMs: DateTime.now().millisecondsSinceEpoch + 8000,
|
||||
),
|
||||
],
|
||||
users: {
|
||||
'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'),
|
||||
},
|
||||
disableAnimations: true,
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Alice is typing…'), findsOneWidget);
|
||||
expect(
|
||||
find.byKey(const ValueKey('channel-typing-shimmer')),
|
||||
findsNothing,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('Compose bar', () {
|
||||
@@ -2099,7 +2261,7 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(TextField), findsNothing);
|
||||
expect(find.byIcon(LucideIcons.arrowUp).hitTestable(), findsNothing);
|
||||
expect(find.byIcon(LucideIcons.arrowUp).hitTestable(), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Message #general'));
|
||||
await tester.pumpAndSettle();
|
||||
@@ -2480,7 +2642,10 @@ void main() {
|
||||
find.byKey(const ValueKey('thread-message-list')),
|
||||
);
|
||||
expect(threadList.reverse, isFalse);
|
||||
expect(threadList.padding!.bottom, Grid.xs);
|
||||
final threadComposerDockHeight = tester
|
||||
.getSize(find.byKey(const ValueKey('thread-composer-dock')))
|
||||
.height;
|
||||
expect(threadList.padding!.bottom, Grid.xs + threadComposerDockHeight);
|
||||
final newestThreadGroup = tester.widget<Padding>(
|
||||
find.byKey(const ValueKey('thread-message-group-reply-next-day')),
|
||||
);
|
||||
@@ -2507,6 +2672,96 @@ void main() {
|
||||
expect(oldestReplyY, lessThan(newestReplyY));
|
||||
});
|
||||
|
||||
testWidgets('thread keeps its tail above a growing composer dock', (
|
||||
tester,
|
||||
) async {
|
||||
tester.view.physicalSize = const Size(400, 800);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
final rootEvent = _textMsg(
|
||||
id: 'thread-root',
|
||||
pubkey: 'alice',
|
||||
content: 'Thread root',
|
||||
createdAt: 1000,
|
||||
);
|
||||
final replies = [
|
||||
for (var i = 0; i < 20; i++)
|
||||
_textMsg(
|
||||
id: 'reply-$i',
|
||||
pubkey: 'bob',
|
||||
content: 'Reply $i',
|
||||
createdAt: 1100 + i,
|
||||
extraTags: const [
|
||||
['e', 'thread-root', '', 'reply'],
|
||||
],
|
||||
),
|
||||
];
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildTestable(
|
||||
messages: [rootEvent],
|
||||
threadReplies: {'thread-root': replies},
|
||||
users: const {
|
||||
'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'),
|
||||
'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'),
|
||||
},
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final threadHead = formatTimeline([rootEvent]).single;
|
||||
Navigator.of(tester.element(find.byType(ChannelDetailPage))).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => ThreadDetailPage(
|
||||
threadHead: threadHead,
|
||||
allMessages: [threadHead],
|
||||
channelId: _channelId,
|
||||
currentPubkey: 'self',
|
||||
isMember: true,
|
||||
isArchived: false,
|
||||
initialMessageId: 'reply-19',
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final dock = find.byKey(const ValueKey('thread-composer-dock'));
|
||||
final latestReply = find.byKey(
|
||||
const ValueKey('thread-message-group-reply-19'),
|
||||
);
|
||||
final composerSurface = find.byKey(const ValueKey('composer-surface'));
|
||||
final compactDockHeight = tester.getSize(dock).height;
|
||||
expect(latestReply, findsOneWidget);
|
||||
expect(
|
||||
tester.getBottomLeft(latestReply).dy,
|
||||
lessThanOrEqualTo(tester.getTopLeft(composerSurface).dy),
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Reply in thread…').hitTestable());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(tester.getSize(dock).height, greaterThan(compactDockHeight));
|
||||
expect(latestReply, findsOneWidget);
|
||||
expect(
|
||||
tester.getBottomLeft(latestReply).dy,
|
||||
lessThanOrEqualTo(tester.getTopLeft(composerSurface).dy),
|
||||
);
|
||||
|
||||
// The dock size change above is separate from the later Scaffold
|
||||
// viewport resize caused by the keyboard. Keep following the tail after
|
||||
// that metrics change too.
|
||||
tester.view.viewInsets = const FakeViewPadding(bottom: 300);
|
||||
addTearDown(tester.view.reset);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
tester.getBottomLeft(latestReply).dy,
|
||||
lessThanOrEqualTo(tester.getTopLeft(composerSurface).dy),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'initial thread hydration keeps the head visible instead of following the tail',
|
||||
(tester) async {
|
||||
@@ -2575,6 +2830,81 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'deep-linking an older reply does not resume tail following on keyboard resize',
|
||||
(tester) async {
|
||||
tester.view.physicalSize = const Size(400, 800);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
addTearDown(tester.view.reset);
|
||||
|
||||
final rootEvent = _textMsg(
|
||||
id: 'thread-root',
|
||||
pubkey: 'alice',
|
||||
content: 'Thread root',
|
||||
createdAt: 1000,
|
||||
);
|
||||
final replies = [
|
||||
for (var i = 0; i < 30; i++)
|
||||
_textMsg(
|
||||
id: 'reply-$i',
|
||||
pubkey: 'bob',
|
||||
content: 'Reply $i',
|
||||
createdAt: 1100 + i,
|
||||
extraTags: const [
|
||||
['e', 'thread-root', '', 'reply'],
|
||||
],
|
||||
),
|
||||
];
|
||||
final completer = Completer<List<NostrEvent>>();
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildTestable(
|
||||
messages: [rootEvent],
|
||||
pendingThreadReplies: {'thread-root': completer.future},
|
||||
users: const {
|
||||
'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'),
|
||||
'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'),
|
||||
},
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final threadHead = formatTimeline([rootEvent]).single;
|
||||
final provisionalTarget = formatTimeline([replies[5]]).single;
|
||||
Navigator.of(tester.element(find.byType(ChannelDetailPage))).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => ThreadDetailPage(
|
||||
threadHead: threadHead,
|
||||
allMessages: [threadHead, provisionalTarget],
|
||||
channelId: _channelId,
|
||||
currentPubkey: 'self',
|
||||
isMember: true,
|
||||
isArchived: false,
|
||||
initialMessageId: 'reply-5',
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
completer.complete(replies);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final target = find.byKey(
|
||||
const ValueKey('thread-message-group-reply-5'),
|
||||
);
|
||||
expect(target, findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Reply in thread…').hitTestable());
|
||||
await tester.pumpAndSettle();
|
||||
tester.view.viewInsets = const FakeViewPadding(bottom: 300);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(target, findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('a reaction landing while the thread is open shows up there', (
|
||||
tester,
|
||||
) async {
|
||||
|
||||
@@ -200,6 +200,80 @@ void main() {
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('section menu matches desktop labels, icons, and inset', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
buildTestable(
|
||||
overrides: [
|
||||
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
|
||||
channelSectionsProvider.overrideWith(
|
||||
() => _FakeChannelSectionsNotifier(
|
||||
const ChannelSectionStore(
|
||||
sections: [
|
||||
ChannelSection(id: 'section-1', name: 'Design', order: 0),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byKey(const Key('section-menu-section-1')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final popover = find.byKey(const Key('section-popover-section-1'));
|
||||
expect(popover, findsOneWidget);
|
||||
for (final label in [
|
||||
'Rename section',
|
||||
'Move up',
|
||||
'Move down',
|
||||
'Delete section',
|
||||
]) {
|
||||
expect(
|
||||
find.descendant(of: popover, matching: find.text(label)),
|
||||
findsOne,
|
||||
);
|
||||
}
|
||||
for (final icon in [
|
||||
LucideIcons.pencil,
|
||||
LucideIcons.arrowUp,
|
||||
LucideIcons.arrowDown,
|
||||
LucideIcons.trash2,
|
||||
]) {
|
||||
expect(
|
||||
find.descendant(of: popover, matching: find.byIcon(icon)),
|
||||
findsOne,
|
||||
);
|
||||
}
|
||||
|
||||
final menuItems = tester.widgetList<PopupMenuItem<String>>(
|
||||
find.descendant(
|
||||
of: popover,
|
||||
matching: find.byWidgetPredicate(
|
||||
(widget) => widget is PopupMenuItem<String>,
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(menuItems, hasLength(4));
|
||||
for (final item in menuItems) {
|
||||
expect(
|
||||
item.padding,
|
||||
const EdgeInsets.fromLTRB(Grid.xs, 0, Grid.twelve, 0),
|
||||
);
|
||||
}
|
||||
|
||||
final error = Theme.of(tester.element(popover)).colorScheme.error;
|
||||
final deleteText = tester.widget<Text>(find.text('Delete section'));
|
||||
final deleteIcon = tester.widget<Icon>(
|
||||
find.descendant(of: popover, matching: find.byIcon(LucideIcons.trash2)),
|
||||
);
|
||||
expect(deleteText.style?.color, error);
|
||||
expect(deleteIcon.color, error);
|
||||
});
|
||||
|
||||
testWidgets('aligns the top, section, row, and skeleton label columns', (
|
||||
tester,
|
||||
) async {
|
||||
|
||||
@@ -23,6 +23,8 @@ import 'package:buzz/shared/custom_emoji/custom_emoji_provider.dart';
|
||||
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:buzz/shared/widgets/anchored_popover_menu.dart';
|
||||
import 'package:buzz/shared/widgets/mobile_tab_footer_backdrop.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
final _pngBytes = Uint8List.fromList([
|
||||
@@ -392,6 +394,152 @@ void main() {
|
||||
});
|
||||
|
||||
group('ComposeBar', () {
|
||||
testWidgets('starts compact and grows to the full-width composer', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
_buildComposeBar(
|
||||
uploadService: _testUploadService(nostr.Keys.generate().nsec),
|
||||
onSend:
|
||||
(
|
||||
content,
|
||||
mentionPubkeys, {
|
||||
mediaTags = const <List<String>>[],
|
||||
}) async {},
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.byType(TextField), findsNothing);
|
||||
expect(find.byTooltip('Add attachment').hitTestable(), findsOneWidget);
|
||||
expect(find.byIcon(LucideIcons.arrowUp).hitTestable(), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('composer-footer-gradient')), findsOne);
|
||||
final composerBackdrop = find.descendant(
|
||||
of: find.byKey(const ValueKey('composer-footer-gradient')),
|
||||
matching: find.byType(MobileTabFooterBackdrop),
|
||||
);
|
||||
expect(composerBackdrop, findsOneWidget);
|
||||
expect(
|
||||
tester.getSize(composerBackdrop).height,
|
||||
mobileTabFooterBackdropHeight(tester.element(composerBackdrop)),
|
||||
);
|
||||
final compactDecoration =
|
||||
tester
|
||||
.widget<Container>(
|
||||
find.byKey(const ValueKey('composer-surface')),
|
||||
)
|
||||
.decoration
|
||||
as BoxDecoration;
|
||||
expect(
|
||||
compactDecoration.borderRadius,
|
||||
BorderRadius.circular(Radii.dialog + Grid.quarter),
|
||||
);
|
||||
final compactWidth = tester
|
||||
.getSize(find.byKey(const ValueKey('composer-width-transition')))
|
||||
.width;
|
||||
|
||||
await _expandComposer(tester);
|
||||
|
||||
final expandedWidth = tester
|
||||
.getSize(find.byKey(const ValueKey('composer-width-transition')))
|
||||
.width;
|
||||
final expandedDecoration =
|
||||
tester
|
||||
.widget<Container>(
|
||||
find.byKey(const ValueKey('composer-surface')),
|
||||
)
|
||||
.decoration
|
||||
as BoxDecoration;
|
||||
expect(compactWidth, closeTo(expandedWidth * 0.85, 0.5));
|
||||
expect(
|
||||
expandedDecoration.borderRadius,
|
||||
BorderRadius.circular(Radii.dialog),
|
||||
);
|
||||
expect(find.byType(TextField), findsOneWidget);
|
||||
expect(find.byIcon(LucideIcons.atSign), findsOneWidget);
|
||||
expect(find.byIcon(LucideIcons.hash), findsOneWidget);
|
||||
expect(find.byIcon(LucideIcons.smilePlus), findsOneWidget);
|
||||
expect(find.byIcon(LucideIcons.aLargeSmall), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('returns to the compact capsule when the keyboard drops', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
_buildComposeBar(
|
||||
uploadService: _testUploadService(nostr.Keys.generate().nsec),
|
||||
onSend:
|
||||
(
|
||||
content,
|
||||
mentionPubkeys, {
|
||||
mediaTags = const <List<String>>[],
|
||||
}) async {},
|
||||
),
|
||||
);
|
||||
await _expandComposer(tester);
|
||||
final focusNode = tester
|
||||
.widget<TextField>(find.byType(TextField))
|
||||
.focusNode!;
|
||||
expect(focusNode.hasFocus, isTrue);
|
||||
tester.view.viewInsets = const FakeViewPadding(bottom: 300);
|
||||
addTearDown(tester.view.reset);
|
||||
await tester.pump();
|
||||
|
||||
tester.view.viewInsets = FakeViewPadding.zero;
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(TextField), findsNothing);
|
||||
expect(focusNode.hasFocus, isFalse);
|
||||
final compactDecoration =
|
||||
tester
|
||||
.widget<Container>(
|
||||
find.byKey(const ValueKey('composer-surface')),
|
||||
)
|
||||
.decoration
|
||||
as BoxDecoration;
|
||||
expect(
|
||||
compactDecoration.borderRadius,
|
||||
BorderRadius.circular(Radii.dialog + Grid.quarter),
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Message\u2026'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(TextField), findsOneWidget);
|
||||
expect(
|
||||
tester.widget<TextField>(find.byType(TextField)).focusNode!.hasFocus,
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('attachment control responds while the composer is expanding', (
|
||||
tester,
|
||||
) async {
|
||||
final previousPlatform = debugDefaultTargetPlatformOverride;
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
||||
try {
|
||||
await tester.pumpWidget(
|
||||
_buildComposeBar(
|
||||
uploadService: _testUploadService(nostr.Keys.generate().nsec),
|
||||
onSend:
|
||||
(
|
||||
content,
|
||||
mentionPubkeys, {
|
||||
mediaTags = const <List<String>>[],
|
||||
}) async {},
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Message\u2026'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 80));
|
||||
await tester.tap(find.byTooltip('Add attachment').hitTestable());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(const ValueKey('attachment-menu')), findsOneWidget);
|
||||
} finally {
|
||||
debugDefaultTargetPlatformOverride = previousPlatform;
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('mounted composer does not carry draft text across an in-place '
|
||||
'identity switch', (tester) async {
|
||||
final keysA = nostr.Keys.generate();
|
||||
@@ -492,6 +640,94 @@ void main() {
|
||||
|
||||
expect(textField.controller!.text, 'hello :meow:world');
|
||||
expect(textField.controller!.selection.baseOffset, 12);
|
||||
expect(find.byType(TextField), findsOneWidget);
|
||||
expect(textField.focusNode!.hasFocus, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('composer controls use selection haptics', (tester) async {
|
||||
final hapticCalls = <MethodCall>[];
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(SystemChannels.platform, (call) async {
|
||||
if (call.method == 'HapticFeedback.vibrate') {
|
||||
hapticCalls.add(call);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
addTearDown(
|
||||
() => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(SystemChannels.platform, null),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildComposeBar(
|
||||
uploadService: _testUploadService(nostr.Keys.generate().nsec),
|
||||
onSend:
|
||||
(
|
||||
content,
|
||||
mentionPubkeys, {
|
||||
mediaTags = const <List<String>>[],
|
||||
}) async {},
|
||||
),
|
||||
);
|
||||
await _expandComposer(tester);
|
||||
hapticCalls.clear();
|
||||
|
||||
await tester.tap(find.byIcon(LucideIcons.atSign));
|
||||
tester.widget<TextField>(find.byType(TextField)).controller!.clear();
|
||||
await tester.pump();
|
||||
await tester.tap(find.byIcon(LucideIcons.hash));
|
||||
await tester.pump();
|
||||
await tester.tap(find.byIcon(LucideIcons.aLargeSmall));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byIcon(LucideIcons.bold));
|
||||
await tester.pump();
|
||||
await tester.tap(find.byTooltip('Close formatting'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byTooltip('Add attachment'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(hapticCalls, hasLength(6));
|
||||
expect(
|
||||
hapticCalls.every(
|
||||
(call) => call.arguments == 'HapticFeedbackType.selectionClick',
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('composer suggestions use the shared popover treatment', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
_buildComposeBar(
|
||||
uploadService: _testUploadService(nostr.Keys.generate().nsec),
|
||||
channels: [_makeChannel(name: 'general', channelType: 'stream')],
|
||||
onSend:
|
||||
(
|
||||
content,
|
||||
mentionPubkeys, {
|
||||
mediaTags = const <List<String>>[],
|
||||
}) async {},
|
||||
),
|
||||
);
|
||||
await _expandComposer(tester);
|
||||
await tester.tap(find.byIcon(LucideIcons.hash));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final surface = find.byKey(const ValueKey('channel-suggestions-popover'));
|
||||
final material = tester.widget<Material>(surface);
|
||||
final shape = material.shape! as RoundedRectangleBorder;
|
||||
expect(shape.borderRadius, BorderRadius.circular(Radii.popover));
|
||||
expect(shape.side.color, Colors.black.withValues(alpha: 0.04));
|
||||
expect(material.elevation, appPopoverElevation);
|
||||
expect(
|
||||
material.shadowColor,
|
||||
appPopoverShadowColor(tester.element(surface)),
|
||||
);
|
||||
expect(
|
||||
tester.widget<Text>(find.text('general')).style?.fontFamily,
|
||||
'Inter',
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('native All Photos picker failures show an error', (
|
||||
@@ -595,6 +831,60 @@ void main() {
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('leaving a focused composer dismisses the native keyboard', (
|
||||
tester,
|
||||
) async {
|
||||
final previousPlatform = debugDefaultTargetPlatformOverride;
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
var dismissCalls = 0;
|
||||
_setMockNativeAttachmentPopoverHandler((call) async {
|
||||
switch (call.method) {
|
||||
case 'isSupported':
|
||||
case 'present':
|
||||
return true;
|
||||
case 'dismiss':
|
||||
dismissCalls += 1;
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
try {
|
||||
await tester.pumpWidget(
|
||||
_buildComposeBar(
|
||||
uploadService: _testUploadService(nostr.Keys.generate().nsec),
|
||||
onSend:
|
||||
(
|
||||
content,
|
||||
mentionPubkeys, {
|
||||
mediaTags = const <List<String>>[],
|
||||
}) async {},
|
||||
),
|
||||
);
|
||||
|
||||
await _expandComposer(tester);
|
||||
final focusNode = tester
|
||||
.widget<TextField>(find.byType(TextField))
|
||||
.focusNode!;
|
||||
expect(focusNode.hasFocus, isTrue);
|
||||
|
||||
await tester.tap(find.byTooltip('Add attachment').hitTestable());
|
||||
await tester.pumpAndSettle();
|
||||
expect(focusNode.hasFocus, isTrue);
|
||||
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(focusNode.hasFocus, isFalse);
|
||||
expect(dismissCalls, 1);
|
||||
} finally {
|
||||
await _sendNativeAttachmentPopoverCall(tester, 'dismissed');
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
_setMockNativeAttachmentPopoverHandler(null);
|
||||
debugDefaultTargetPlatformOverride = previousPlatform;
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'unsupported iOS attachment popover unfocuses before fallback menu',
|
||||
(tester) async {
|
||||
@@ -1191,6 +1481,10 @@ void main() {
|
||||
),
|
||||
);
|
||||
|
||||
final compactComposerWidth = tester
|
||||
.getSize(find.byKey(const ValueKey('composer-width-transition')))
|
||||
.width;
|
||||
|
||||
await _openAttachmentMenu(tester);
|
||||
await tester.tap(find.text('Photos'));
|
||||
await tester.pumpAndSettle();
|
||||
@@ -1201,6 +1495,12 @@ void main() {
|
||||
);
|
||||
expect(find.byTooltip('Back to attachment options'), findsWidgets);
|
||||
expect(find.text('All photos'), findsOneWidget);
|
||||
expect(
|
||||
tester
|
||||
.getSize(find.byKey(const ValueKey('attachment-surface-popover')))
|
||||
.width,
|
||||
closeTo(compactComposerWidth / 0.85, 0.5),
|
||||
);
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('recent-photo-two')));
|
||||
await tester.pumpAndSettle();
|
||||
@@ -1265,12 +1565,22 @@ void main() {
|
||||
await _openAttachmentMenu(tester);
|
||||
|
||||
final menu = find.byKey(const ValueKey('attachment-menu'));
|
||||
final surface = find.byKey(const ValueKey('attachment-surface-popover'));
|
||||
final rows = [
|
||||
for (final label in ['camera', 'photos', 'video', 'files'])
|
||||
find.byKey(ValueKey('attachment-menu-item-$label')),
|
||||
];
|
||||
final menuRect = tester.getRect(menu);
|
||||
|
||||
final material = tester.widget<Material>(surface);
|
||||
final shape = material.shape! as RoundedRectangleBorder;
|
||||
expect(shape.borderRadius, BorderRadius.circular(Radii.popover));
|
||||
expect(shape.side.color, Colors.black.withValues(alpha: 0.04));
|
||||
expect(material.elevation, appPopoverElevation);
|
||||
expect(
|
||||
material.shadowColor,
|
||||
appPopoverShadowColor(tester.element(surface)),
|
||||
);
|
||||
expect(menuRect.size, const Size(216, 264));
|
||||
for (final row in rows) {
|
||||
expect(tester.getSize(row).height, 52);
|
||||
@@ -1280,6 +1590,7 @@ void main() {
|
||||
for (final label in ['Camera', 'Photos', 'Video', 'Files']) {
|
||||
final text = tester.widget<Text>(find.text(label));
|
||||
expect(text.style?.fontSize, 20);
|
||||
expect(text.style?.fontFamily, 'Inter');
|
||||
}
|
||||
final icons = [
|
||||
for (final label in ['camera', 'photos', 'video', 'files'])
|
||||
@@ -1319,6 +1630,48 @@ void main() {
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('tapping outside dismisses the Android attachment menu', (
|
||||
tester,
|
||||
) async {
|
||||
final previousPlatform = debugDefaultTargetPlatformOverride;
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
||||
try {
|
||||
await tester.pumpWidget(
|
||||
_buildComposeBar(
|
||||
uploadService: _testUploadService(nostr.Keys.generate().nsec),
|
||||
onSend:
|
||||
(
|
||||
content,
|
||||
mentionPubkeys, {
|
||||
mediaTags = const <List<String>>[],
|
||||
}) async {},
|
||||
),
|
||||
);
|
||||
|
||||
await _openAttachmentMenu(tester);
|
||||
expect(find.byKey(const ValueKey('attachment-menu')), findsOneWidget);
|
||||
expect(
|
||||
find.byKey(const ValueKey('attachment-dismiss-barrier')),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
await tester.tapAt(const Offset(24, 24));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(const ValueKey('attachment-menu')), findsNothing);
|
||||
expect(
|
||||
find.byKey(const ValueKey('attachment-dismiss-barrier')),
|
||||
findsNothing,
|
||||
);
|
||||
expect(
|
||||
find.byKey(const ValueKey('attachment-trigger-closed')).hitTestable(),
|
||||
findsOneWidget,
|
||||
);
|
||||
} finally {
|
||||
debugDefaultTargetPlatformOverride = previousPlatform;
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'attachment menu grows rows and scrolls for accessibility text',
|
||||
(tester) async {
|
||||
@@ -1376,6 +1729,9 @@ void main() {
|
||||
}) async {},
|
||||
),
|
||||
);
|
||||
final compactComposerWidth = tester
|
||||
.getSize(find.byKey(const ValueKey('composer-width-transition')))
|
||||
.width;
|
||||
|
||||
await _openAttachmentMenu(tester);
|
||||
await tester.tap(find.text('Camera'));
|
||||
@@ -1397,6 +1753,12 @@ void main() {
|
||||
find.byKey(const ValueKey('camera-initialization-ready')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
tester
|
||||
.getSize(find.byKey(const ValueKey('attachment-surface-popover')))
|
||||
.width,
|
||||
closeTo(compactComposerWidth / 0.85, 0.5),
|
||||
);
|
||||
} finally {
|
||||
debugDefaultTargetPlatformOverride = previousPlatform;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import 'package:buzz/shared/emoji/native_emoji_glyph.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('lifts the glyph one logical pixel on iOS', (tester) async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
try {
|
||||
await tester.pumpWidget(
|
||||
const MaterialApp(
|
||||
home: Center(child: NativeEmojiGlyph(emoji: '🔥', size: 24)),
|
||||
),
|
||||
);
|
||||
|
||||
final transform = tester.widget<Transform>(find.byType(Transform));
|
||||
expect(transform.transform.getTranslation().y, -1);
|
||||
} finally {
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('keeps the glyph unshifted on Android', (tester) async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
||||
try {
|
||||
await tester.pumpWidget(
|
||||
const MaterialApp(
|
||||
home: Center(child: NativeEmojiGlyph(emoji: '🔥', size: 24)),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.byType(Transform), findsNothing);
|
||||
} finally {
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -7,4 +7,21 @@ void main() {
|
||||
expect(AppTheme.light().splashFactory, NoSplash.splashFactory);
|
||||
expect(AppTheme.dark().splashFactory, NoSplash.splashFactory);
|
||||
});
|
||||
|
||||
test('uses Inter and the shared elevated popover treatment', () {
|
||||
final theme = AppTheme.light();
|
||||
final popupTheme = theme.popupMenuTheme;
|
||||
final shape = popupTheme.shape! as RoundedRectangleBorder;
|
||||
final side = shape.side;
|
||||
|
||||
expect(popupTheme.textStyle?.fontFamily, 'Inter');
|
||||
expect(popupTheme.elevation, 8);
|
||||
expect(
|
||||
popupTheme.shadowColor,
|
||||
theme.colorScheme.shadow.withValues(alpha: 0.18),
|
||||
);
|
||||
expect(shape.borderRadius, BorderRadius.circular(Radii.popover));
|
||||
expect(side.color, Colors.black.withValues(alpha: 0.04));
|
||||
expect(side.width, 1);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -130,6 +130,13 @@ void main() {
|
||||
lineHeight: 17,
|
||||
letterSpacing: 0,
|
||||
);
|
||||
expectStyle(
|
||||
filterChipTextStyle,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w400,
|
||||
lineHeight: 15,
|
||||
letterSpacing: 0,
|
||||
);
|
||||
});
|
||||
|
||||
test('message and activity avatars use their surface sizes', () {
|
||||
|
||||
@@ -39,10 +39,57 @@ void main() {
|
||||
final unselectedLabel = tester.widget<Text>(find.text('Following'));
|
||||
expect(selectedLabel.style?.fontSize, filterChipTextStyle.fontSize);
|
||||
expect(selectedLabel.style?.height, filterChipTextStyle.height);
|
||||
expect(selectedLabel.style?.fontFamily, 'Inter');
|
||||
expect(selectedLabel.style?.fontWeight, FontWeight.w500);
|
||||
expect(unselectedLabel.style?.fontSize, filterChipTextStyle.fontSize);
|
||||
expect(unselectedLabel.style?.height, filterChipTextStyle.height);
|
||||
expect(unselectedLabel.style?.fontWeight, FontWeight.w400);
|
||||
final chip = tester.widget<FilterChip>(
|
||||
find.widgetWithText(FilterChip, 'Everyone'),
|
||||
);
|
||||
final shape = chip.shape! as RoundedRectangleBorder;
|
||||
expect(shape.borderRadius, BorderRadius.circular(Radii.lg));
|
||||
expect(chip.labelPadding, EdgeInsets.zero);
|
||||
});
|
||||
|
||||
testWidgets('search labels are vertically centered in their chips', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
home: Scaffold(
|
||||
body: SizedBox(
|
||||
width: 390,
|
||||
child: FilterChipBar<int>(
|
||||
expandItems: true,
|
||||
visualDensity: const VisualDensity(horizontal: -2),
|
||||
chipVerticalPadding: Grid.xxs,
|
||||
barVerticalPadding: Grid.twelve,
|
||||
selected: 0,
|
||||
onSelected: (_) {},
|
||||
items: const [
|
||||
FilterChipItem(id: 0, label: 'All'),
|
||||
FilterChipItem(id: 1, label: 'Messages'),
|
||||
FilterChipItem(id: 2, label: 'Channels'),
|
||||
FilterChipItem(id: 3, label: 'People'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
for (final label in ['All', 'Messages', 'Channels', 'People']) {
|
||||
final text = find.text(label);
|
||||
final chip = find.ancestor(of: text, matching: find.byType(RawChip));
|
||||
expect(chip, findsOneWidget);
|
||||
expect(
|
||||
tester.getCenter(text).dy,
|
||||
closeTo(tester.getCenter(chip).dy, 0.01),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('expanded chips preserve large accessible text scaling', (
|
||||
@@ -81,7 +128,10 @@ void main() {
|
||||
),
|
||||
findsNothing,
|
||||
);
|
||||
expect(tester.getSize(find.text('Messages')).height, greaterThan(32));
|
||||
expect(
|
||||
tester.getSize(find.text('Messages')).height,
|
||||
greaterThanOrEqualTo(30),
|
||||
);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,7 +4,10 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
/// A focused field inside a `Scaffold` body, which is the only arrangement
|
||||
/// either message list ever runs in.
|
||||
Widget _testable({required FocusNode focusNode}) {
|
||||
Widget _testable({
|
||||
required FocusNode focusNode,
|
||||
VoidCallback? onUserScrollStart,
|
||||
}) {
|
||||
return MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Column(
|
||||
@@ -12,6 +15,7 @@ Widget _testable({required FocusNode focusNode}) {
|
||||
TextField(focusNode: focusNode),
|
||||
Expanded(
|
||||
child: KeyboardDismissOnDrag(
|
||||
onUserScrollStart: onUserScrollStart,
|
||||
child: ListView(
|
||||
children: [
|
||||
for (var i = 0; i < 40; i++)
|
||||
@@ -104,6 +108,23 @@ void main() {
|
||||
expect(focusNode.hasFocus, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('reports a user-started scroll', (tester) async {
|
||||
final focusNode = FocusNode();
|
||||
addTearDown(focusNode.dispose);
|
||||
var userScrollStarts = 0;
|
||||
await tester.pumpWidget(
|
||||
_testable(
|
||||
focusNode: focusNode,
|
||||
onUserScrollStart: () => userScrollStarts += 1,
|
||||
),
|
||||
);
|
||||
|
||||
await tester.drag(find.text('row 3'), const Offset(0, -100));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(userScrollStarts, 1);
|
||||
});
|
||||
|
||||
testWidgets('an upward drag never dismisses, however far it goes', (
|
||||
tester,
|
||||
) async {
|
||||
|
||||
@@ -3,6 +3,28 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('shared gradient fades from transparent to the page surface', (
|
||||
tester,
|
||||
) async {
|
||||
LinearGradient? gradient;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Builder(
|
||||
builder: (context) {
|
||||
gradient = mobileTabFooterBackdropGradient(context);
|
||||
return const SizedBox();
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(gradient?.stops, [0, 0.5, 1]);
|
||||
expect(gradient?.colors.first.a, 0);
|
||||
expect(gradient?.colors[1].a, 0.75);
|
||||
expect(gradient?.colors.last.a, 1);
|
||||
});
|
||||
|
||||
testWidgets('uses the logical bottom safe-area inset', (tester) async {
|
||||
double? height;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user