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:
klopez4212
2026-08-03 07:30:23 -07:00
committed by GitHub
parent 83a285f1b1
commit 857e63c4dd
47 changed files with 2384 additions and 662 deletions
-11
View File
@@ -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 {
+48 -41
View File
@@ -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
+22 -13
View File
@@ -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);
+66 -60
View File
@@ -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);
}
}
+15 -5
View File
@@ -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,
+13 -5
View File
@@ -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;