mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Polish mobile bottom sheets and profile cards (#4911)
## Summary - standardize mobile sheets with shared spacing, close controls, action tiles, haptics, and motion - add uniform native concentric corners on iOS 26+ while preserving the Android sheet shape - refresh profile actions/status and normalize membership and huddle timeline spacing ## Validation - `just mobile-check` - `just mobile-test` (1,165 tests) - signed iPhone Release build and device install - Android debug build and Pixel 10 install ## Snapshots ### Channel actions  ### Profile card  --------- Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
@@ -13,6 +13,7 @@
|
||||
4A71C0012F40100100A17E01 /* InlinePhotoPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C0022F40100100A17E01 /* InlinePhotoPicker.swift */; };
|
||||
4A71C0032F40200100A17E01 /* NativeAttachmentPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C0042F40200100A17E01 /* NativeAttachmentPopover.swift */; };
|
||||
4A71C0052F40300100A17E01 /* NativeAttachmentPopoverCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C0062F40300100A17E01 /* NativeAttachmentPopoverCoordinator.swift */; };
|
||||
4A71C0072F40400100A17E01 /* ConcentricSheetSurface.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C0082F40400100A17E01 /* ConcentricSheetSurface.swift */; };
|
||||
331C809D294A63AB00263BE5 /* UIKitEncoded.png in Resources */ = {isa = PBXBuildFile; fileRef = 331C809C294A618700263BE5 /* UIKitEncoded.png */; };
|
||||
331C809F294A63AB00263BE5 /* UIKitEncoded.jpg in Resources */ = {isa = PBXBuildFile; fileRef = 331C809E294A618700263BE5 /* UIKitEncoded.jpg */; };
|
||||
33ADD70AB275E0EC81295559 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8906419FB4E98B4B12B7A56F /* Pods_Runner.framework */; };
|
||||
@@ -57,6 +58,7 @@
|
||||
4A71C0022F40100100A17E01 /* InlinePhotoPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlinePhotoPicker.swift; sourceTree = "<group>"; };
|
||||
4A71C0042F40200100A17E01 /* NativeAttachmentPopover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeAttachmentPopover.swift; sourceTree = "<group>"; };
|
||||
4A71C0062F40300100A17E01 /* NativeAttachmentPopoverCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeAttachmentPopoverCoordinator.swift; sourceTree = "<group>"; };
|
||||
4A71C0082F40400100A17E01 /* ConcentricSheetSurface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConcentricSheetSurface.swift; sourceTree = "<group>"; };
|
||||
331C809C294A618700263BE5 /* UIKitEncoded.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = UIKitEncoded.png; sourceTree = "<group>"; };
|
||||
331C809E294A618700263BE5 /* UIKitEncoded.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = UIKitEncoded.jpg; sourceTree = "<group>"; };
|
||||
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
@@ -179,6 +181,7 @@
|
||||
4A71C0022F40100100A17E01 /* InlinePhotoPicker.swift */,
|
||||
4A71C0042F40200100A17E01 /* NativeAttachmentPopover.swift */,
|
||||
4A71C0062F40300100A17E01 /* NativeAttachmentPopoverCoordinator.swift */,
|
||||
4A71C0082F40400100A17E01 /* ConcentricSheetSurface.swift */,
|
||||
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
|
||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
|
||||
);
|
||||
@@ -413,6 +416,7 @@
|
||||
4A71C0012F40100100A17E01 /* InlinePhotoPicker.swift in Sources */,
|
||||
4A71C0032F40200100A17E01 /* NativeAttachmentPopover.swift in Sources */,
|
||||
4A71C0052F40300100A17E01 /* NativeAttachmentPopoverCoordinator.swift in Sources */,
|
||||
4A71C0072F40400100A17E01 /* ConcentricSheetSurface.swift in Sources */,
|
||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
|
||||
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */,
|
||||
);
|
||||
|
||||
@@ -8,6 +8,7 @@ import UserNotifications
|
||||
private var mediaUploadChannel: FlutterMethodChannel?
|
||||
private var qrScannerChannel: FlutterMethodChannel?
|
||||
private var inlinePhotoPickerSupportChannel: FlutterMethodChannel?
|
||||
private var concentricSheetSurfaceChannel: FlutterMethodChannel?
|
||||
private var nativeAttachmentPopoverCoordinator: NativeAttachmentPopoverCoordinator?
|
||||
|
||||
override func application(
|
||||
@@ -63,6 +64,30 @@ import UserNotifications
|
||||
)
|
||||
}
|
||||
|
||||
if let concentricSheetRegistrar = engineBridge.pluginRegistry.registrar(
|
||||
forPlugin: "BuzzConcentricSheetSurface"
|
||||
) {
|
||||
concentricSheetRegistrar.register(
|
||||
ConcentricSheetSurfaceFactory(),
|
||||
withId: "buzz/concentric_sheet_surface"
|
||||
)
|
||||
concentricSheetSurfaceChannel = FlutterMethodChannel(
|
||||
name: "buzz/concentric_sheet_surface",
|
||||
binaryMessenger: messenger
|
||||
)
|
||||
concentricSheetSurfaceChannel?.setMethodCallHandler { call, result in
|
||||
guard call.method == "isSupported" else {
|
||||
result(FlutterMethodNotImplemented)
|
||||
return
|
||||
}
|
||||
if #available(iOS 26.0, *) {
|
||||
result(true)
|
||||
} else {
|
||||
result(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let nativeAttachmentRegistrar = engineBridge.pluginRegistry.registrar(
|
||||
forPlugin: "BuzzNativeAttachmentPopover"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import Flutter
|
||||
import UIKit
|
||||
|
||||
final class ConcentricSheetSurfaceFactory: NSObject, FlutterPlatformViewFactory {
|
||||
func createArgsCodec() -> FlutterMessageCodec & NSObjectProtocol {
|
||||
FlutterStandardMessageCodec.sharedInstance()
|
||||
}
|
||||
|
||||
func create(
|
||||
withFrame frame: CGRect,
|
||||
viewIdentifier viewId: Int64,
|
||||
arguments args: Any?
|
||||
) -> FlutterPlatformView {
|
||||
ConcentricSheetSurfacePlatformView(frame: frame, arguments: args)
|
||||
}
|
||||
}
|
||||
|
||||
final class ConcentricSheetSurfacePlatformView: NSObject, FlutterPlatformView {
|
||||
private let surfaceView: UIView
|
||||
|
||||
init(frame: CGRect, arguments args: Any?) {
|
||||
let arguments = args as? [String: Any]
|
||||
let colorValue = (arguments?["color"] as? NSNumber)?.uint32Value ?? 0xFFFFFFFF
|
||||
let minimumRadius = (arguments?["minimumRadius"] as? NSNumber)?.doubleValue ?? 24
|
||||
|
||||
surfaceView = UIView(frame: frame)
|
||||
surfaceView.isOpaque = true
|
||||
surfaceView.backgroundColor = Self.color(from: colorValue)
|
||||
surfaceView.clipsToBounds = true
|
||||
surfaceView.layer.cornerCurve = .continuous
|
||||
|
||||
if #available(iOS 26.0, *) {
|
||||
surfaceView.cornerConfiguration = .uniformCorners(
|
||||
radius: .containerConcentric(minimum: minimumRadius)
|
||||
)
|
||||
} else {
|
||||
surfaceView.layer.cornerRadius = minimumRadius
|
||||
}
|
||||
|
||||
super.init()
|
||||
}
|
||||
|
||||
func view() -> UIView {
|
||||
surfaceView
|
||||
}
|
||||
|
||||
private static func color(from value: UInt32) -> UIColor {
|
||||
let alpha = CGFloat((value >> 24) & 0xFF) / 255
|
||||
let red = CGFloat((value >> 16) & 0xFF) / 255
|
||||
let green = CGFloat((value >> 8) & 0xFF) / 255
|
||||
let blue = CGFloat(value & 0xFF) / 255
|
||||
return UIColor(red: red, green: green, blue: blue, alpha: alpha)
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import '../../shared/widgets/buzz_loading_indicator.dart';
|
||||
import '../../shared/widgets/frosted_app_bar.dart';
|
||||
import '../../shared/widgets/frosted_scaffold.dart';
|
||||
import '../../shared/widgets/message_author_meta.dart';
|
||||
import '../../shared/widgets/modal_presentation.dart';
|
||||
import '../channels/channel.dart';
|
||||
import '../channels/channel_detail_page.dart';
|
||||
import '../channels/channels_provider.dart';
|
||||
|
||||
@@ -340,32 +340,42 @@ class _InboxRow extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
void _showRowActions(BuildContext context) {
|
||||
showModalBottomSheet<void>(
|
||||
showBuzzModalBottomSheet<void>(
|
||||
context: context,
|
||||
builder: (sheetContext) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
isDone ? LucideIcons.mail : LucideIcons.mailOpen,
|
||||
size: 20,
|
||||
),
|
||||
title: Text(isDone ? 'Mark unread' : 'Mark as read'),
|
||||
onTap: () {
|
||||
Navigator.of(sheetContext).pop();
|
||||
isDone ? onMarkUnread() : onMarkRead();
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
Grid.gutter,
|
||||
0,
|
||||
Grid.gutter,
|
||||
Grid.xs,
|
||||
),
|
||||
child: IconTheme.merge(
|
||||
data: const IconThemeData(size: 18),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
isDone ? LucideIcons.mail : LucideIcons.mailOpen,
|
||||
),
|
||||
title: Text(isDone ? 'Mark unread' : 'Mark as read'),
|
||||
onTap: () {
|
||||
Navigator.of(sheetContext).pop();
|
||||
isDone ? onMarkUnread() : onMarkRead();
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(LucideIcons.externalLink),
|
||||
title: const Text('Open conversation'),
|
||||
onTap: () {
|
||||
Navigator.of(sheetContext).pop();
|
||||
onTap();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(LucideIcons.externalLink, size: 20),
|
||||
title: const Text('Open conversation'),
|
||||
onTap: () {
|
||||
Navigator.of(sheetContext).pop();
|
||||
onTap();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
@@ -6,6 +9,7 @@ import '../../shared/clipboard_utils.dart';
|
||||
import '../../shared/mentions/agent_identity_provider.dart';
|
||||
import '../../shared/theme/theme.dart';
|
||||
import '../../shared/widgets/buzz_loading_indicator.dart';
|
||||
import '../../shared/widgets/modal_presentation.dart';
|
||||
import '../../shared/widgets/sheet_divider.dart';
|
||||
import 'channel.dart';
|
||||
import 'channel_management_provider.dart';
|
||||
@@ -25,7 +29,7 @@ Future<bool?> showChannelActionsSheet({
|
||||
required bool isUnread,
|
||||
VoidCallback? onMarkRead,
|
||||
String? sectionId,
|
||||
}) => showModalBottomSheet<bool>(
|
||||
}) => showBuzzModalBottomSheet<bool>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
showDragHandle: true,
|
||||
@@ -103,215 +107,223 @@ class ChannelActionsSheet extends ConsumerWidget {
|
||||
|
||||
return SafeArea(
|
||||
top: false,
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
Grid.gutter,
|
||||
0,
|
||||
Grid.gutter,
|
||||
Grid.xs,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (!channel.isDm) ...[
|
||||
_ChannelQuickActionsRow(
|
||||
isStarred: isStarred,
|
||||
isUnread: isUnread,
|
||||
onToggleStar: () {
|
||||
close();
|
||||
final notifier = ref.read(channelStarsProvider.notifier);
|
||||
isStarred
|
||||
? notifier.unstarChannel(channel.id)
|
||||
: notifier.starChannel(channel.id);
|
||||
},
|
||||
onToggleRead: () {
|
||||
close();
|
||||
final timestamp = dateTimeToUnixSeconds(
|
||||
channel.lastMessageAt,
|
||||
);
|
||||
if (isUnread) {
|
||||
onMarkRead?.call();
|
||||
if (timestamp != null) {
|
||||
child: IconTheme.merge(
|
||||
data: const IconThemeData(size: 22),
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
Grid.gutter,
|
||||
0,
|
||||
Grid.gutter,
|
||||
Grid.xs,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (!channel.isDm) ...[
|
||||
_ChannelQuickActionsRow(
|
||||
isStarred: isStarred,
|
||||
isUnread: isUnread,
|
||||
onToggleStar: () {
|
||||
close();
|
||||
final notifier = ref.read(channelStarsProvider.notifier);
|
||||
isStarred
|
||||
? notifier.unstarChannel(channel.id)
|
||||
: notifier.starChannel(channel.id);
|
||||
},
|
||||
onToggleRead: () {
|
||||
close();
|
||||
final timestamp = dateTimeToUnixSeconds(
|
||||
channel.lastMessageAt,
|
||||
);
|
||||
if (isUnread) {
|
||||
onMarkRead?.call();
|
||||
if (timestamp != null) {
|
||||
ref
|
||||
.read(readStateProvider.notifier)
|
||||
.markContextRead(
|
||||
channel.id,
|
||||
timestamp,
|
||||
clearForcedMessages: true,
|
||||
);
|
||||
ref
|
||||
.read(channelsProvider.notifier)
|
||||
.clearObservedUnreadCoveredByRead(
|
||||
channel.id,
|
||||
timestamp,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
ref
|
||||
.read(readStateProvider.notifier)
|
||||
.markContextRead(
|
||||
channel.id,
|
||||
timestamp,
|
||||
clearForcedMessages: true,
|
||||
);
|
||||
ref
|
||||
.read(channelsProvider.notifier)
|
||||
.clearObservedUnreadCoveredByRead(
|
||||
channel.id,
|
||||
timestamp,
|
||||
);
|
||||
.markContextUnread(channel.id, channelId: channel.id);
|
||||
}
|
||||
} else {
|
||||
ref
|
||||
.read(readStateProvider.notifier)
|
||||
.markContextUnread(channel.id, channelId: channel.id);
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: Grid.xs),
|
||||
],
|
||||
if (!channel.isDm)
|
||||
ListTile(
|
||||
leading: const Icon(LucideIcons.folderInput),
|
||||
title: const Text('Move to section…'),
|
||||
onTap: () async {
|
||||
final pageContext = Navigator.of(
|
||||
context,
|
||||
rootNavigator: true,
|
||||
).context;
|
||||
close();
|
||||
await _showMoveSectionSheet(
|
||||
pageContext,
|
||||
ref,
|
||||
channel: channel,
|
||||
sectionId: sectionId,
|
||||
);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: Icon(isMuted ? LucideIcons.bell : LucideIcons.bellOff),
|
||||
title: Text(isMuted ? 'Unmute channel' : 'Mute channel'),
|
||||
onTap: () {
|
||||
close();
|
||||
final notifier = ref.read(channelMutesProvider.notifier);
|
||||
isMuted
|
||||
? notifier.unmuteChannel(channel.id)
|
||||
: notifier.muteChannel(channel.id);
|
||||
},
|
||||
),
|
||||
if (!channel.isDm)
|
||||
ListTile(
|
||||
leading: const Icon(LucideIcons.settings),
|
||||
title: const Text('Manage channel'),
|
||||
onTap: () async {
|
||||
final shouldClose = await showModalBottomSheet<bool>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
showDragHandle: true,
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: 640,
|
||||
maxHeight: MediaQuery.sizeOf(context).height * 0.9,
|
||||
),
|
||||
builder: (_) => ManageChannelSheet(channel: channel),
|
||||
);
|
||||
if (shouldClose == true && context.mounted) {
|
||||
Navigator.of(context).pop(true);
|
||||
}
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(LucideIcons.copy),
|
||||
title: const Text('Copy channel name'),
|
||||
onTap: () {
|
||||
close();
|
||||
copyToClipboard(
|
||||
context,
|
||||
channel.name,
|
||||
message: 'Channel name copied to clipboard',
|
||||
);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(LucideIcons.hash),
|
||||
title: const Text('Copy channel ID'),
|
||||
onTap: () {
|
||||
close();
|
||||
copyToClipboard(
|
||||
context,
|
||||
channel.id,
|
||||
message: 'Channel ID copied to clipboard',
|
||||
);
|
||||
},
|
||||
),
|
||||
if (!channel.isDm) ...[
|
||||
const SheetDivider(),
|
||||
if (channel.isMember && !channel.isArchived)
|
||||
_ActionTile(
|
||||
icon: LucideIcons.logOut,
|
||||
label: 'Leave channel',
|
||||
destructive: true,
|
||||
onTap: () => _confirmAndRun(
|
||||
context,
|
||||
ref,
|
||||
title: 'Leave #${channel.name}?',
|
||||
body: 'You’ll stop receiving messages from this channel.',
|
||||
confirmLabel: 'Leave',
|
||||
action: () => ref
|
||||
.read(channelActionsProvider)
|
||||
.leaveChannel(channel.id),
|
||||
),
|
||||
},
|
||||
),
|
||||
if (lifecycleCapabilitiesLoading)
|
||||
const ListTile(
|
||||
enabled: false,
|
||||
leading: BuzzLoadingIndicator(
|
||||
size: 20,
|
||||
semanticLabel: 'Loading channel actions',
|
||||
),
|
||||
title: Text('Loading channel actions…'),
|
||||
)
|
||||
else if (lifecycleCapabilitiesUnavailable)
|
||||
const ListTile(
|
||||
enabled: false,
|
||||
leading: Icon(LucideIcons.triangleAlert),
|
||||
title: Text('Channel actions unavailable'),
|
||||
)
|
||||
else ...[
|
||||
if (canArchive)
|
||||
_ActionTile(
|
||||
icon: LucideIcons.archive,
|
||||
label: 'Archive channel',
|
||||
onTap: () => _confirmAndRun(
|
||||
const SizedBox(height: Grid.xs),
|
||||
],
|
||||
if (!channel.isDm)
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(LucideIcons.folderInput),
|
||||
title: const Text('Move to section…'),
|
||||
onTap: () async {
|
||||
final pageContext = Navigator.of(
|
||||
context,
|
||||
rootNavigator: true,
|
||||
).context;
|
||||
close();
|
||||
await _showMoveSectionSheet(
|
||||
pageContext,
|
||||
ref,
|
||||
title: 'Archive #${channel.name}?',
|
||||
body: 'The channel will become read-only.',
|
||||
confirmLabel: 'Archive',
|
||||
action: () => ref
|
||||
.read(channelActionsProvider)
|
||||
.archiveChannel(channel.id),
|
||||
),
|
||||
),
|
||||
if (canUnarchive)
|
||||
channel: channel,
|
||||
sectionId: sectionId,
|
||||
);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: Icon(isMuted ? LucideIcons.bell : LucideIcons.bellOff),
|
||||
title: Text(isMuted ? 'Unmute channel' : 'Mute channel'),
|
||||
onTap: () {
|
||||
close();
|
||||
final notifier = ref.read(channelMutesProvider.notifier);
|
||||
isMuted
|
||||
? notifier.unmuteChannel(channel.id)
|
||||
: notifier.muteChannel(channel.id);
|
||||
},
|
||||
),
|
||||
if (!channel.isDm)
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(LucideIcons.settings),
|
||||
title: const Text('Manage channel'),
|
||||
onTap: () async {
|
||||
final shouldClose = await showBuzzModalBottomSheet<bool>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
showDragHandle: true,
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: 640,
|
||||
maxHeight: MediaQuery.sizeOf(context).height * 0.9,
|
||||
),
|
||||
builder: (_) => ManageChannelSheet(channel: channel),
|
||||
);
|
||||
if (shouldClose == true && context.mounted) {
|
||||
Navigator.of(context).pop(true);
|
||||
}
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(LucideIcons.copy),
|
||||
title: const Text('Copy channel name'),
|
||||
onTap: () {
|
||||
close();
|
||||
copyToClipboard(
|
||||
context,
|
||||
channel.name,
|
||||
message: 'Channel name copied to clipboard',
|
||||
);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(LucideIcons.hash),
|
||||
title: const Text('Copy channel ID'),
|
||||
onTap: () {
|
||||
close();
|
||||
copyToClipboard(
|
||||
context,
|
||||
channel.id,
|
||||
message: 'Channel ID copied to clipboard',
|
||||
);
|
||||
},
|
||||
),
|
||||
if (!channel.isDm) ...[
|
||||
const SheetDivider(),
|
||||
if (channel.isMember && !channel.isArchived)
|
||||
_ActionTile(
|
||||
icon: LucideIcons.archiveRestore,
|
||||
label: 'Unarchive channel',
|
||||
onTap: () => _confirmAndRun(
|
||||
context,
|
||||
ref,
|
||||
title: 'Unarchive #${channel.name}?',
|
||||
body: 'The channel will become active again.',
|
||||
confirmLabel: 'Unarchive',
|
||||
action: () => ref
|
||||
.read(channelActionsProvider)
|
||||
.unarchiveChannel(channel.id),
|
||||
),
|
||||
),
|
||||
if (canDelete)
|
||||
_ActionTile(
|
||||
icon: LucideIcons.trash2,
|
||||
label: 'Delete channel',
|
||||
icon: LucideIcons.logOut,
|
||||
label: 'Leave channel',
|
||||
destructive: true,
|
||||
onTap: () => _confirmAndRun(
|
||||
context,
|
||||
ref,
|
||||
title: 'Delete #${channel.name}?',
|
||||
body:
|
||||
'This permanently deletes the channel and cannot be undone.',
|
||||
confirmLabel: 'Delete',
|
||||
title: 'Leave #${channel.name}?',
|
||||
body: 'You’ll stop receiving messages from this channel.',
|
||||
confirmLabel: 'Leave',
|
||||
action: () => ref
|
||||
.read(channelActionsProvider)
|
||||
.deleteChannel(channel.id),
|
||||
.leaveChannel(channel.id),
|
||||
),
|
||||
),
|
||||
if (lifecycleCapabilitiesLoading)
|
||||
const ListTile(
|
||||
enabled: false,
|
||||
leading: BuzzLoadingIndicator(
|
||||
size: 20,
|
||||
semanticLabel: 'Loading channel actions',
|
||||
),
|
||||
title: Text('Loading channel actions…'),
|
||||
)
|
||||
else if (lifecycleCapabilitiesUnavailable)
|
||||
const ListTile(
|
||||
enabled: false,
|
||||
leading: Icon(LucideIcons.triangleAlert),
|
||||
title: Text('Channel actions unavailable'),
|
||||
)
|
||||
else ...[
|
||||
if (canArchive)
|
||||
_ActionTile(
|
||||
icon: LucideIcons.archive,
|
||||
label: 'Archive channel',
|
||||
onTap: () => _confirmAndRun(
|
||||
context,
|
||||
ref,
|
||||
title: 'Archive #${channel.name}?',
|
||||
body: 'The channel will become read-only.',
|
||||
confirmLabel: 'Archive',
|
||||
action: () => ref
|
||||
.read(channelActionsProvider)
|
||||
.archiveChannel(channel.id),
|
||||
),
|
||||
),
|
||||
if (canUnarchive)
|
||||
_ActionTile(
|
||||
icon: LucideIcons.archiveRestore,
|
||||
label: 'Unarchive channel',
|
||||
onTap: () => _confirmAndRun(
|
||||
context,
|
||||
ref,
|
||||
title: 'Unarchive #${channel.name}?',
|
||||
body: 'The channel will become active again.',
|
||||
confirmLabel: 'Unarchive',
|
||||
action: () => ref
|
||||
.read(channelActionsProvider)
|
||||
.unarchiveChannel(channel.id),
|
||||
),
|
||||
),
|
||||
if (canDelete)
|
||||
_ActionTile(
|
||||
icon: LucideIcons.trash2,
|
||||
label: 'Delete channel',
|
||||
destructive: true,
|
||||
onTap: () => _confirmAndRun(
|
||||
context,
|
||||
ref,
|
||||
title: 'Delete #${channel.name}?',
|
||||
body:
|
||||
'This permanently deletes the channel and cannot be undone.',
|
||||
confirmLabel: 'Delete',
|
||||
action: () => ref
|
||||
.read(channelActionsProvider)
|
||||
.deleteChannel(channel.id),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -333,17 +345,21 @@ class _ChannelQuickActionsRow extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
_ChannelQuickAction(
|
||||
icon: isStarred ? LucideIcons.starOff : LucideIcons.star,
|
||||
label: isStarred ? 'Unstar' : 'Star',
|
||||
onTap: onToggleStar,
|
||||
Expanded(
|
||||
child: _ChannelQuickAction(
|
||||
icon: isStarred ? LucideIcons.starOff : LucideIcons.star,
|
||||
label: isStarred ? 'Unstar' : 'Star',
|
||||
onTap: onToggleStar,
|
||||
),
|
||||
),
|
||||
_ChannelQuickAction(
|
||||
icon: isUnread ? LucideIcons.checkCheck : LucideIcons.circleDot,
|
||||
label: isUnread ? 'Mark Read' : 'Mark Unread',
|
||||
onTap: onToggleRead,
|
||||
const SizedBox(width: Grid.twelve),
|
||||
Expanded(
|
||||
child: _ChannelQuickAction(
|
||||
icon: isUnread ? LucideIcons.checkCheck : LucideIcons.circleDot,
|
||||
label: isUnread ? 'Mark Read' : 'Mark Unread',
|
||||
onTap: onToggleRead,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -362,28 +378,35 @@ class _ChannelQuickAction extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => GestureDetector(
|
||||
onTap: onTap,
|
||||
onTap: () {
|
||||
unawaited(HapticFeedback.lightImpact());
|
||||
onTap();
|
||||
},
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Container(
|
||||
width: 76,
|
||||
height: 56,
|
||||
alignment: Alignment.center,
|
||||
height: 68 + (Grid.xxs * 2),
|
||||
decoration: BoxDecoration(
|
||||
color: context.colors.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(Radii.dialog),
|
||||
),
|
||||
child: Icon(icon, size: 24, color: context.colors.onSurface),
|
||||
),
|
||||
const SizedBox(height: Grid.xxs),
|
||||
Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: context.textTheme.labelMedium?.copyWith(
|
||||
color: context.colors.onSurface,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(icon, size: 22, color: context.colors.onSurface),
|
||||
const SizedBox(height: Grid.xxs),
|
||||
Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: context.textTheme.labelMedium?.copyWith(
|
||||
color: context.colors.onSurface,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -406,12 +429,16 @@ class _ActionTile extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: Icon(icon, color: destructive ? context.colors.error : null),
|
||||
title: Text(
|
||||
label,
|
||||
style: destructive ? TextStyle(color: context.colors.error) : null,
|
||||
),
|
||||
onTap: onTap,
|
||||
onTap: () {
|
||||
unawaited(HapticFeedback.lightImpact());
|
||||
onTap();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -424,7 +451,7 @@ Future<void> _confirmAndRun(
|
||||
required Future<void> Function() action,
|
||||
}) async {
|
||||
final pageContext = Navigator.of(sheetContext, rootNavigator: true).context;
|
||||
final confirmed = await showDialog<bool>(
|
||||
final confirmed = await showBuzzDialog<bool>(
|
||||
context: pageContext,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: Text(title),
|
||||
@@ -465,69 +492,72 @@ Future<void> _showMoveSectionSheet(
|
||||
}) async {
|
||||
final sections = [...ref.read(channelSectionsProvider).store.sections]
|
||||
..sort((a, b) => a.order.compareTo(b.order));
|
||||
await showModalBottomSheet<void>(
|
||||
await showBuzzModalBottomSheet<void>(
|
||||
context: context,
|
||||
showDragHandle: true,
|
||||
builder: (sheetContext) => SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
Grid.gutter,
|
||||
0,
|
||||
Grid.gutter,
|
||||
Grid.xs,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (final section in sections)
|
||||
child: IconTheme.merge(
|
||||
data: const IconThemeData(size: 22),
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
Grid.gutter,
|
||||
0,
|
||||
Grid.gutter,
|
||||
Grid.xs,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (final section in sections)
|
||||
ListTile(
|
||||
leading: const Icon(LucideIcons.folder),
|
||||
title: Text(section.name),
|
||||
trailing: sectionId == section.id
|
||||
? Icon(
|
||||
LucideIcons.check,
|
||||
color: sheetContext.colors.primary,
|
||||
)
|
||||
: null,
|
||||
onTap: () {
|
||||
Navigator.of(sheetContext).pop();
|
||||
ref
|
||||
.read(channelSectionsProvider.notifier)
|
||||
.assignChannel(channel.id, section.id);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(LucideIcons.folder),
|
||||
title: Text(section.name),
|
||||
trailing: sectionId == section.id
|
||||
? Icon(
|
||||
LucideIcons.check,
|
||||
color: sheetContext.colors.primary,
|
||||
)
|
||||
: null,
|
||||
onTap: () {
|
||||
leading: const Icon(LucideIcons.folderPlus),
|
||||
title: const Text('New section…'),
|
||||
onTap: () async {
|
||||
Navigator.of(sheetContext).pop();
|
||||
ref
|
||||
.read(channelSectionsProvider.notifier)
|
||||
.assignChannel(channel.id, section.id);
|
||||
final name = await _showSectionNameDialog(context);
|
||||
if (name == null || name.isEmpty) return;
|
||||
final notifier = ref.read(channelSectionsProvider.notifier);
|
||||
notifier.createSection(name);
|
||||
final created = ref
|
||||
.read(channelSectionsProvider)
|
||||
.store
|
||||
.sections
|
||||
.where((section) => section.name == name.trim())
|
||||
.lastOrNull;
|
||||
if (created != null) {
|
||||
notifier.assignChannel(channel.id, created.id);
|
||||
}
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(LucideIcons.folderPlus),
|
||||
title: const Text('New section…'),
|
||||
onTap: () async {
|
||||
Navigator.of(sheetContext).pop();
|
||||
final name = await _showSectionNameDialog(context);
|
||||
if (name == null || name.isEmpty) return;
|
||||
final notifier = ref.read(channelSectionsProvider.notifier);
|
||||
notifier.createSection(name);
|
||||
final created = ref
|
||||
.read(channelSectionsProvider)
|
||||
.store
|
||||
.sections
|
||||
.where((section) => section.name == name.trim())
|
||||
.lastOrNull;
|
||||
if (created != null) {
|
||||
notifier.assignChannel(channel.id, created.id);
|
||||
}
|
||||
},
|
||||
),
|
||||
if (sectionId != null)
|
||||
ListTile(
|
||||
leading: const Icon(LucideIcons.folderMinus),
|
||||
title: const Text('Remove from section'),
|
||||
onTap: () {
|
||||
Navigator.of(sheetContext).pop();
|
||||
ref
|
||||
.read(channelSectionsProvider.notifier)
|
||||
.unassignChannel(channel.id);
|
||||
},
|
||||
),
|
||||
],
|
||||
if (sectionId != null)
|
||||
ListTile(
|
||||
leading: const Icon(LucideIcons.folderMinus),
|
||||
title: const Text('Remove from section'),
|
||||
onTap: () {
|
||||
Navigator.of(sheetContext).pop();
|
||||
ref
|
||||
.read(channelSectionsProvider.notifier)
|
||||
.unassignChannel(channel.id);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -536,7 +566,7 @@ Future<void> _showMoveSectionSheet(
|
||||
|
||||
Future<String?> _showSectionNameDialog(BuildContext context) async {
|
||||
final controller = TextEditingController();
|
||||
final result = await showDialog<String>(
|
||||
final result = await showBuzzDialog<String>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text('New Section'),
|
||||
|
||||
@@ -18,6 +18,7 @@ import '../../shared/widgets/frosted_app_bar.dart';
|
||||
import '../../shared/widgets/frosted_scaffold.dart';
|
||||
import '../../shared/widgets/keyboard_dismiss_on_drag.dart';
|
||||
import '../../shared/widgets/message_author_meta.dart';
|
||||
import '../../shared/widgets/modal_presentation.dart';
|
||||
import '../../shared/widgets/skeleton.dart';
|
||||
import '../profile/presence_cache_provider.dart';
|
||||
import '../profile/profile_provider.dart';
|
||||
|
||||
@@ -39,7 +39,7 @@ class _MembersButton extends ConsumerWidget {
|
||||
return IconButton(
|
||||
color: context.colors.primary,
|
||||
onPressed: () {
|
||||
showModalBottomSheet<void>(
|
||||
showBuzzModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
showDragHandle: true,
|
||||
|
||||
@@ -97,7 +97,10 @@ class _SystemMessageRow extends ConsumerWidget {
|
||||
isArchived: isArchived,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: Grid.xxs),
|
||||
padding: EdgeInsets.only(
|
||||
top: usesMessageStyleLayout ? Grid.xs : Grid.xxs,
|
||||
bottom: usesMessageStyleLayout ? 0 : Grid.xxs,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -341,10 +344,14 @@ class _MessageStyleSystemMessageContent extends StatelessWidget {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_UserAvatar(
|
||||
profile: userCache[displayPubkey.toLowerCase()],
|
||||
pubkey: displayPubkey,
|
||||
size: messageAvatarSize,
|
||||
GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => showUserProfileSheet(context, displayPubkey),
|
||||
child: _UserAvatar(
|
||||
profile: userCache[displayPubkey.toLowerCase()],
|
||||
pubkey: displayPubkey,
|
||||
size: messageAvatarSize,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: messageAvatarContentGap),
|
||||
Expanded(
|
||||
@@ -449,12 +456,23 @@ Widget _systemEventAvatar(
|
||||
height: 20,
|
||||
child: Stack(
|
||||
children: [
|
||||
SmallAvatar(pubkey: event.actorPubkey!, userCache: userCache),
|
||||
GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => showUserProfileSheet(context, event.actorPubkey!),
|
||||
child: SmallAvatar(
|
||||
pubkey: event.actorPubkey!,
|
||||
userCache: userCache,
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 12,
|
||||
child: SmallAvatar(
|
||||
pubkey: event.targetPubkey!,
|
||||
userCache: userCache,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => showUserProfileSheet(context, event.targetPubkey!),
|
||||
child: SmallAvatar(
|
||||
pubkey: event.targetPubkey!,
|
||||
userCache: userCache,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -463,7 +481,11 @@ Widget _systemEventAvatar(
|
||||
}
|
||||
|
||||
if (event.actorPubkey != null) {
|
||||
return SmallAvatar(pubkey: event.actorPubkey!, userCache: userCache);
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => showUserProfileSheet(context, event.actorPubkey!),
|
||||
child: SmallAvatar(pubkey: event.actorPubkey!, userCache: userCache),
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback: generic icon when no actor is available.
|
||||
|
||||
@@ -20,6 +20,7 @@ import '../../shared/widgets/anchored_popover_menu.dart';
|
||||
import '../../shared/widgets/buzz_loading_indicator.dart';
|
||||
import '../../shared/widgets/frosted_app_bar.dart';
|
||||
import '../../shared/widgets/frosted_scaffold.dart';
|
||||
import '../../shared/widgets/modal_presentation.dart';
|
||||
import '../../shared/widgets/skeleton.dart';
|
||||
import '../../shared/custom_emoji/custom_emoji.dart';
|
||||
import '../../shared/custom_emoji/custom_emoji_provider.dart';
|
||||
@@ -299,7 +300,7 @@ class ChannelsPage extends HookConsumerWidget {
|
||||
void openCommunitySwitcher() {
|
||||
unawaited(HapticFeedback.selectionClick());
|
||||
ref.invalidate(communityIconProvider);
|
||||
showModalBottomSheet<void>(
|
||||
showBuzzModalBottomSheet<void>(
|
||||
context: context,
|
||||
showDragHandle: true,
|
||||
builder: (_) => const _CommunitySwitcherSheet(),
|
||||
|
||||
@@ -278,7 +278,7 @@ class _SliverChannelsList extends HookConsumerWidget {
|
||||
userSections.first.id != section.id,
|
||||
onToggle: () => toggleSection(section.id),
|
||||
onRename: () async {
|
||||
final name = await showDialog<String>(
|
||||
final name = await showBuzzDialog<String>(
|
||||
context: context,
|
||||
builder: (_) => _SectionNameDialog(
|
||||
title: 'Rename Section',
|
||||
@@ -293,7 +293,7 @@ class _SliverChannelsList extends HookConsumerWidget {
|
||||
}
|
||||
},
|
||||
onDelete: () async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
final confirmed = await showBuzzDialog<bool>(
|
||||
context: context,
|
||||
builder: (_) => AlertDialog(
|
||||
title: Text('Delete "${section.name}"?'),
|
||||
|
||||
@@ -422,7 +422,7 @@ Future<void> _confirmRemoveCommunity(
|
||||
Community community, {
|
||||
required bool closeSheetAfterRemoval,
|
||||
}) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
final confirmed = await showBuzzDialog<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog.adaptive(
|
||||
title: const Text('Remove community?'),
|
||||
|
||||
@@ -85,7 +85,7 @@ class ChannelQuickActionsLauncher extends HookConsumerWidget {
|
||||
|
||||
switch (action) {
|
||||
case _QuickAction.createChannel:
|
||||
final created = await showModalBottomSheet<Channel>(
|
||||
final created = await showBuzzModalBottomSheet<Channel>(
|
||||
context: context,
|
||||
constraints: _quickActionSheetConstraints(context),
|
||||
isScrollControlled: true,
|
||||
@@ -96,7 +96,7 @@ class ChannelQuickActionsLauncher extends HookConsumerWidget {
|
||||
await openChannel(created);
|
||||
}
|
||||
case _QuickAction.newDm:
|
||||
final opened = await showModalBottomSheet<Channel>(
|
||||
final opened = await showBuzzModalBottomSheet<Channel>(
|
||||
context: context,
|
||||
constraints: _quickActionSheetConstraints(context),
|
||||
isScrollControlled: true,
|
||||
|
||||
@@ -25,6 +25,7 @@ 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 '../../shared/widgets/modal_presentation.dart';
|
||||
import '../profile/user_cache_provider.dart';
|
||||
import '../profile/user_profile.dart';
|
||||
import '../../shared/custom_emoji/custom_emoji.dart';
|
||||
|
||||
@@ -160,7 +160,7 @@ Future<_NonMemberMentionChoice?> _promptNonMemberMention(
|
||||
required bool canInvite,
|
||||
}) {
|
||||
final verb = names.length == 1 ? 'is' : 'are';
|
||||
return showDialog<_NonMemberMentionChoice>(
|
||||
return showBuzzDialog<_NonMemberMentionChoice>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text('Mention people outside this channel?'),
|
||||
|
||||
@@ -11,6 +11,7 @@ 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 '../../shared/widgets/modal_presentation.dart';
|
||||
import 'recent_emoji_provider.dart';
|
||||
|
||||
part 'emoji_picker/search_field.dart';
|
||||
@@ -33,7 +34,7 @@ void showEmojiPicker({
|
||||
required void Function(String emoji) onSelect,
|
||||
VoidCallback? onDismiss,
|
||||
}) {
|
||||
showModalBottomSheet<void>(
|
||||
showBuzzModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
showDragHandle: true,
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import '../../shared/theme/theme.dart';
|
||||
import '../../shared/widgets/avatar_image.dart';
|
||||
import '../../shared/widgets/buzz_loading_indicator.dart';
|
||||
import '../../shared/widgets/modal_presentation.dart';
|
||||
import '../profile/user_cache_provider.dart';
|
||||
import '../profile/user_profile.dart';
|
||||
import '../profile/user_status.dart';
|
||||
@@ -50,7 +51,7 @@ class MembersSheet extends HookConsumerWidget {
|
||||
navigator.pop();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!navigator.mounted) return;
|
||||
showModalBottomSheet<void>(
|
||||
showBuzzModalBottomSheet<void>(
|
||||
context: navigator.context,
|
||||
isScrollControlled: true,
|
||||
showDragHandle: true,
|
||||
@@ -302,7 +303,7 @@ class _MemberTile extends ConsumerWidget {
|
||||
? profile!.displayName!.trim()
|
||||
: member.labelFor(currentPubkey));
|
||||
final canChangeRole = showManagementActions && !member.isBot;
|
||||
showModalBottomSheet<void>(
|
||||
showBuzzModalBottomSheet<void>(
|
||||
context: context,
|
||||
showDragHandle: true,
|
||||
builder: (sheetContext) => SafeArea(
|
||||
@@ -360,7 +361,7 @@ class _MemberTile extends ConsumerWidget {
|
||||
),
|
||||
onTap: () async {
|
||||
Navigator.of(context).pop();
|
||||
final confirmed = await showDialog<bool>(
|
||||
final confirmed = await showBuzzDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Remove member'),
|
||||
|
||||
@@ -19,6 +19,7 @@ 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/widgets/modal_presentation.dart';
|
||||
import '../../shared/reminders/remind_me_later_sheet.dart';
|
||||
import '../../shared/reminders/reminder_service.dart';
|
||||
import 'channel_management_provider.dart';
|
||||
@@ -47,97 +48,104 @@ void showMessageActions({
|
||||
bool isMember = false,
|
||||
bool isArchived = false,
|
||||
}) {
|
||||
showModalBottomSheet<void>(
|
||||
showBuzzModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
showDragHandle: true,
|
||||
showCloseButton: false,
|
||||
builder: (sheetContext) => SafeArea(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.sizeOf(sheetContext).height * 0.7,
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
Grid.gutter,
|
||||
0,
|
||||
Grid.gutter,
|
||||
Grid.xs,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_QuickReactionRow(
|
||||
message: message,
|
||||
sheetContext: sheetContext,
|
||||
pageContext: context,
|
||||
pageRef: ref,
|
||||
),
|
||||
const SizedBox(height: Grid.xs),
|
||||
if (!message.isSystem) ...[
|
||||
// Fast actions: respond now, hand off context, defer.
|
||||
_FastActionsRow(
|
||||
child: IconTheme.merge(
|
||||
data: const IconThemeData(size: 22),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.sizeOf(sheetContext).height * 0.7,
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
Grid.gutter,
|
||||
0,
|
||||
Grid.gutter,
|
||||
Grid.xs,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_QuickReactionRow(
|
||||
message: message,
|
||||
channelId: channelId,
|
||||
allMessages: allMessages,
|
||||
currentPubkey: currentPubkey,
|
||||
isMember: isMember,
|
||||
isArchived: isArchived,
|
||||
sheetContext: sheetContext,
|
||||
pageContext: context,
|
||||
pageRef: ref,
|
||||
),
|
||||
const SizedBox(height: Grid.xs),
|
||||
// Triage: come back to this message later.
|
||||
_MarkReadUnreadTile(message: message, channelId: channelId),
|
||||
_FollowThreadTile(message: message),
|
||||
const SheetDivider(),
|
||||
// Export: take the content out of the conversation.
|
||||
ListTile(
|
||||
leading: const Icon(LucideIcons.copy),
|
||||
title: const Text('Copy text'),
|
||||
onTap: () {
|
||||
Navigator.of(sheetContext).pop();
|
||||
// Copy to clipboard
|
||||
final data = ClipboardData(text: message.content);
|
||||
Clipboard.setData(data);
|
||||
},
|
||||
),
|
||||
],
|
||||
if (canManageMessage) ...[
|
||||
if (!message.isSystem) const SheetDivider(),
|
||||
ListTile(
|
||||
leading: const Icon(LucideIcons.pencil),
|
||||
title: const Text('Edit message'),
|
||||
onTap: () {
|
||||
Navigator.of(sheetContext).pop();
|
||||
_showEditSheet(
|
||||
context: context,
|
||||
ref: ref,
|
||||
message: message,
|
||||
channelId: channelId,
|
||||
);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
LucideIcons.trash2,
|
||||
color: sheetContext.colors.error,
|
||||
if (!message.isSystem) ...[
|
||||
// Fast actions: respond now, hand off context, defer.
|
||||
_FastActionsRow(
|
||||
message: message,
|
||||
channelId: channelId,
|
||||
allMessages: allMessages,
|
||||
currentPubkey: currentPubkey,
|
||||
isMember: isMember,
|
||||
isArchived: isArchived,
|
||||
pageContext: context,
|
||||
),
|
||||
title: Text(
|
||||
'Delete message',
|
||||
style: TextStyle(color: sheetContext.colors.error),
|
||||
const SizedBox(height: Grid.xs),
|
||||
// Triage: come back to this message later.
|
||||
_MarkReadUnreadTile(message: message, channelId: channelId),
|
||||
_FollowThreadTile(message: message),
|
||||
const SheetDivider(),
|
||||
// Export: take the content out of the conversation.
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(LucideIcons.copy),
|
||||
title: const Text('Copy text'),
|
||||
onTap: () {
|
||||
Navigator.of(sheetContext).pop();
|
||||
// Copy to clipboard
|
||||
final data = ClipboardData(text: message.content);
|
||||
Clipboard.setData(data);
|
||||
},
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.of(sheetContext).pop();
|
||||
_confirmDelete(
|
||||
context: context,
|
||||
ref: ref,
|
||||
channelId: channelId,
|
||||
messageId: message.id,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
if (canManageMessage) ...[
|
||||
if (!message.isSystem) const SheetDivider(),
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(LucideIcons.pencil),
|
||||
title: const Text('Edit message'),
|
||||
onTap: () {
|
||||
Navigator.of(sheetContext).pop();
|
||||
_showEditSheet(
|
||||
context: context,
|
||||
ref: ref,
|
||||
message: message,
|
||||
channelId: channelId,
|
||||
);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: Icon(
|
||||
LucideIcons.trash2,
|
||||
color: sheetContext.colors.error,
|
||||
),
|
||||
title: Text(
|
||||
'Delete message',
|
||||
style: TextStyle(color: sheetContext.colors.error),
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.of(sheetContext).pop();
|
||||
_confirmDelete(
|
||||
context: context,
|
||||
ref: ref,
|
||||
channelId: channelId,
|
||||
messageId: message.id,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -156,78 +164,91 @@ void showImageActions({
|
||||
required bool canManageMessage,
|
||||
VoidCallback? onDeleted,
|
||||
}) {
|
||||
showModalBottomSheet<void>(
|
||||
showBuzzModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
showDragHandle: true,
|
||||
builder: (sheetContext) => SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
Grid.gutter,
|
||||
0,
|
||||
Grid.gutter,
|
||||
Grid.xs,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(LucideIcons.download),
|
||||
title: const Text('Save image'),
|
||||
onTap: () {
|
||||
Navigator.of(sheetContext).pop();
|
||||
unawaited(_saveImage(context, ref, imageUrl));
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(LucideIcons.share2),
|
||||
title: const Text('Share image'),
|
||||
onTap: () {
|
||||
final renderBox = context.findRenderObject() as RenderBox?;
|
||||
final shareOrigin = renderBox == null
|
||||
? null
|
||||
: renderBox.localToGlobal(Offset.zero) & renderBox.size;
|
||||
Navigator.of(sheetContext).pop();
|
||||
unawaited(
|
||||
_shareImage(context, ref, imageUrl, shareOrigin: shareOrigin),
|
||||
);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(LucideIcons.link2),
|
||||
title: const Text('Copy image link'),
|
||||
onTap: () {
|
||||
Navigator.of(sheetContext).pop();
|
||||
copyToClipboard(
|
||||
context,
|
||||
imageUrl,
|
||||
message: 'Image link copied',
|
||||
);
|
||||
},
|
||||
),
|
||||
if (canManageMessage) ...[
|
||||
const SheetDivider(),
|
||||
child: IconTheme.merge(
|
||||
data: const IconThemeData(size: 22),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
Grid.gutter,
|
||||
0,
|
||||
Grid.gutter,
|
||||
Grid.xs,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
LucideIcons.trash2,
|
||||
color: sheetContext.colors.error,
|
||||
),
|
||||
title: Text(
|
||||
'Delete message',
|
||||
style: TextStyle(color: sheetContext.colors.error),
|
||||
),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(LucideIcons.download),
|
||||
title: const Text('Save image'),
|
||||
onTap: () {
|
||||
Navigator.of(sheetContext).pop();
|
||||
_confirmDelete(
|
||||
context: context,
|
||||
ref: ref,
|
||||
channelId: channelId,
|
||||
messageId: message.id,
|
||||
onDeleted: onDeleted,
|
||||
unawaited(_saveImage(context, ref, imageUrl));
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(LucideIcons.share2),
|
||||
title: const Text('Share image'),
|
||||
onTap: () {
|
||||
final renderBox = context.findRenderObject() as RenderBox?;
|
||||
final shareOrigin = renderBox == null
|
||||
? null
|
||||
: renderBox.localToGlobal(Offset.zero) & renderBox.size;
|
||||
Navigator.of(sheetContext).pop();
|
||||
unawaited(
|
||||
_shareImage(
|
||||
context,
|
||||
ref,
|
||||
imageUrl,
|
||||
shareOrigin: shareOrigin,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(LucideIcons.link2),
|
||||
title: const Text('Copy image link'),
|
||||
onTap: () {
|
||||
Navigator.of(sheetContext).pop();
|
||||
copyToClipboard(
|
||||
context,
|
||||
imageUrl,
|
||||
message: 'Image link copied',
|
||||
);
|
||||
},
|
||||
),
|
||||
if (canManageMessage) ...[
|
||||
const SheetDivider(),
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: Icon(
|
||||
LucideIcons.trash2,
|
||||
color: sheetContext.colors.error,
|
||||
),
|
||||
title: Text(
|
||||
'Delete message',
|
||||
style: TextStyle(color: sheetContext.colors.error),
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.of(sheetContext).pop();
|
||||
_confirmDelete(
|
||||
context: context,
|
||||
ref: ref,
|
||||
channelId: channelId,
|
||||
messageId: message.id,
|
||||
onDeleted: onDeleted,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -387,6 +408,7 @@ class _MarkReadUnreadTile extends ConsumerWidget {
|
||||
);
|
||||
|
||||
return ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: Icon(unread ? LucideIcons.mailCheck : LucideIcons.mailOpen),
|
||||
title: Text(unread ? 'Mark read' : 'Mark unread'),
|
||||
onTap: () {
|
||||
@@ -428,6 +450,7 @@ class _FollowThreadTile extends ConsumerWidget {
|
||||
final following = follows.isFollowing(rootId);
|
||||
|
||||
return ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: Icon(following ? LucideIcons.bellOff : LucideIcons.bellRing),
|
||||
title: Text(following ? 'Unfollow thread' : 'Follow thread'),
|
||||
onTap: () {
|
||||
@@ -533,8 +556,12 @@ class _FastActionsRow extends ConsumerWidget {
|
||||
];
|
||||
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: tiles,
|
||||
children: [
|
||||
for (var index = 0; index < tiles.length; index++) ...[
|
||||
Expanded(child: tiles[index]),
|
||||
if (index < tiles.length - 1) const SizedBox(width: Grid.twelve),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -553,30 +580,37 @@ class _FastActionTile extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
onTap: () {
|
||||
unawaited(HapticFeedback.lightImpact());
|
||||
onTap();
|
||||
},
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Pill-shaped icon container with the caption below the fill,
|
||||
// matching the emoji circles' fill and tap treatment.
|
||||
// The full-width tiles deliberately contrast with the compact emoji
|
||||
// reactions immediately above them.
|
||||
Container(
|
||||
width: 76,
|
||||
height: 56,
|
||||
alignment: Alignment.center,
|
||||
height: 68 + (Grid.xxs * 2),
|
||||
decoration: BoxDecoration(
|
||||
color: context.colors.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(Radii.dialog),
|
||||
),
|
||||
child: Icon(icon, size: 24, color: context.colors.onSurface),
|
||||
),
|
||||
const SizedBox(height: Grid.xxs),
|
||||
Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: context.textTheme.labelMedium?.copyWith(
|
||||
color: context.colors.onSurface,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(icon, size: 22, color: context.colors.onSurface),
|
||||
const SizedBox(height: Grid.xxs),
|
||||
Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: context.textTheme.labelMedium?.copyWith(
|
||||
color: context.colors.onSurface,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -636,32 +670,59 @@ class _QuickReactionRow extends ConsumerWidget {
|
||||
pageRef.read(channelActionsProvider).addReaction(message.id, value);
|
||||
}
|
||||
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
for (final value in emoji)
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
const desiredCircleSize = 52.0;
|
||||
const minimumCircleSize = 44.0;
|
||||
final itemCount = emoji.length + 1;
|
||||
final gapCount = itemCount - 1;
|
||||
final circleSize =
|
||||
((constraints.maxWidth - (Grid.twelve * gapCount)) / itemCount)
|
||||
.clamp(minimumCircleSize, desiredCircleSize)
|
||||
.toDouble();
|
||||
final gap =
|
||||
((constraints.maxWidth - (circleSize * itemCount)) / gapCount)
|
||||
.clamp(0.0, Grid.twelve)
|
||||
.toDouble();
|
||||
final circles = <Widget>[
|
||||
for (final value in emoji)
|
||||
_QuickReactionCircle(
|
||||
key: ValueKey('quick-reaction-$value'),
|
||||
size: circleSize,
|
||||
onTap: () {
|
||||
Navigator.of(sheetContext).pop();
|
||||
react(value);
|
||||
},
|
||||
child: _QuickReactionGlyph(
|
||||
value: value,
|
||||
customByShortcode: customByShortcode,
|
||||
),
|
||||
),
|
||||
_QuickReactionCircle(
|
||||
key: const ValueKey('quick-reaction-more'),
|
||||
size: circleSize,
|
||||
onTap: () {
|
||||
Navigator.of(sheetContext).pop();
|
||||
react(value);
|
||||
showEmojiPicker(context: pageContext, onSelect: react);
|
||||
},
|
||||
child: _QuickReactionGlyph(
|
||||
value: value,
|
||||
customByShortcode: customByShortcode,
|
||||
child: Icon(
|
||||
LucideIcons.plus,
|
||||
size: 24,
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
_QuickReactionCircle(
|
||||
onTap: () {
|
||||
Navigator.of(sheetContext).pop();
|
||||
showEmojiPicker(context: pageContext, onSelect: react);
|
||||
},
|
||||
child: Icon(
|
||||
LucideIcons.plus,
|
||||
size: 24,
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
];
|
||||
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
for (var index = 0; index < circles.length; index++) ...[
|
||||
circles[index],
|
||||
if (index < circles.length - 1) SizedBox(width: gap),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -699,16 +760,25 @@ class _QuickReactionGlyph extends StatelessWidget {
|
||||
class _QuickReactionCircle extends StatelessWidget {
|
||||
final VoidCallback onTap;
|
||||
final Widget child;
|
||||
final double size;
|
||||
|
||||
const _QuickReactionCircle({required this.onTap, required this.child});
|
||||
const _QuickReactionCircle({
|
||||
super.key,
|
||||
required this.onTap,
|
||||
required this.child,
|
||||
required this.size,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
onTap: () {
|
||||
unawaited(HapticFeedback.lightImpact());
|
||||
onTap();
|
||||
},
|
||||
child: Container(
|
||||
width: 52,
|
||||
height: 52,
|
||||
width: size,
|
||||
height: size,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: context.colors.surfaceContainerHighest,
|
||||
@@ -727,7 +797,7 @@ void _showEditSheet({
|
||||
required String channelId,
|
||||
}) {
|
||||
final controller = TextEditingController(text: message.content);
|
||||
showModalBottomSheet<void>(
|
||||
showBuzzModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
showDragHandle: true,
|
||||
@@ -794,7 +864,7 @@ void _confirmDelete({
|
||||
required String messageId,
|
||||
VoidCallback? onDeleted,
|
||||
}) {
|
||||
showDialog<void>(
|
||||
showBuzzDialog<void>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text('Delete message'),
|
||||
|
||||
@@ -108,6 +108,9 @@ class MessageContent extends HookConsumerWidget {
|
||||
|
||||
final TextStyle? baseStyle;
|
||||
|
||||
/// Alignment applied to rendered markdown text.
|
||||
final TextAlign? textAlign;
|
||||
|
||||
final int? maxLines;
|
||||
|
||||
/// Render a body that is nothing but emoji at [kEmojiOnlyFontSize], the way
|
||||
@@ -137,6 +140,7 @@ class MessageContent extends HookConsumerWidget {
|
||||
this.onMediaReply,
|
||||
this.onMediaMore,
|
||||
this.baseStyle,
|
||||
this.textAlign,
|
||||
this.maxLines,
|
||||
this.scaleEmojiOnly = false,
|
||||
this.mediaCarouselLeadingOverflow = 0,
|
||||
@@ -269,6 +273,7 @@ class MessageContent extends HookConsumerWidget {
|
||||
_buildLink(context, ref, linkText, url, linkStyle, style),
|
||||
imageBuilder: (context, imageUrl) =>
|
||||
_buildMedia(context, imageUrl, imetaByUrl[imageUrl]),
|
||||
textAlign: textAlign,
|
||||
maxLines: maxLines,
|
||||
inlineComponents: [
|
||||
_MentionMd(
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import '../../shared/theme/theme.dart';
|
||||
import '../../shared/widgets/avatar_image.dart';
|
||||
import '../../shared/widgets/modal_presentation.dart';
|
||||
import '../../shared/custom_emoji/custom_emoji_render.dart';
|
||||
import '../../shared/emoji/emoji_burst.dart';
|
||||
import '../../shared/emoji/emoji_data_provider.dart';
|
||||
@@ -316,7 +317,7 @@ void showReactionDetailSheet({
|
||||
required List<TimelineReaction> reactions,
|
||||
required String initialEmoji,
|
||||
}) {
|
||||
showModalBottomSheet<void>(
|
||||
showBuzzModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
showDragHandle: true,
|
||||
|
||||
@@ -17,13 +17,14 @@ import '../../shared/theme/theme_provider.dart';
|
||||
/// leak across an in-place community or account switch.
|
||||
const _recentEmojiPrefsKey = 'buzz.quick-reaction-emojis.v1';
|
||||
|
||||
/// Desktop's `DEFAULT_QUICK_REACTIONS`, used until the user has reacted enough
|
||||
/// to fill the row.
|
||||
/// Desktop's `DEFAULT_QUICK_REACTIONS`, plus one mobile-only slot that fits in
|
||||
/// the compact action sheet, used until the user has reacted enough to fill it.
|
||||
const defaultQuickEmojis = <String>[
|
||||
'\u{1F44D}',
|
||||
'\u{2764}\u{FE0F}',
|
||||
'\u{1F602}',
|
||||
'\u{1F389}',
|
||||
'\u{1F525}',
|
||||
];
|
||||
|
||||
/// Desktop's `MAX_STORED_REACTIONS`.
|
||||
@@ -167,7 +168,7 @@ final recentEmojiProvider =
|
||||
List<String> quickReactionEmoji(
|
||||
List<RecentEmojiEntry> entries, {
|
||||
required Set<String> customShortcodes,
|
||||
int limit = 4,
|
||||
int limit = 5,
|
||||
}) {
|
||||
final result = <String>[];
|
||||
void add(String emoji) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import '../../shared/mentions/agent_identity_provider.dart';
|
||||
import '../../shared/theme/theme.dart';
|
||||
import '../../shared/widgets/avatar_image.dart';
|
||||
import '../../shared/widgets/modal_presentation.dart';
|
||||
import '../channels/message_content.dart';
|
||||
import '../profile/user_cache_provider.dart';
|
||||
import '../profile/user_profile_sheet.dart';
|
||||
@@ -230,44 +231,47 @@ class ForumPostCard extends HookConsumerWidget {
|
||||
currentPubkey != null &&
|
||||
post.pubkey.toLowerCase() == currentPubkey!.toLowerCase();
|
||||
|
||||
showModalBottomSheet<void>(
|
||||
showBuzzModalBottomSheet<void>(
|
||||
context: context,
|
||||
showDragHandle: true,
|
||||
builder: (sheetContext) => SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
Grid.gutter,
|
||||
0,
|
||||
Grid.gutter,
|
||||
Grid.xs,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(LucideIcons.copy),
|
||||
title: const Text('Copy text'),
|
||||
onTap: () {
|
||||
Navigator.of(sheetContext).pop();
|
||||
Clipboard.setData(ClipboardData(text: post.content));
|
||||
},
|
||||
),
|
||||
if (isOwn && onDelete != null)
|
||||
child: IconTheme.merge(
|
||||
data: const IconThemeData(size: 22),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
Grid.gutter,
|
||||
0,
|
||||
Grid.gutter,
|
||||
Grid.xs,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
LucideIcons.trash2,
|
||||
color: sheetContext.colors.error,
|
||||
),
|
||||
title: Text(
|
||||
'Delete post',
|
||||
style: TextStyle(color: sheetContext.colors.error),
|
||||
),
|
||||
leading: const Icon(LucideIcons.copy),
|
||||
title: const Text('Copy text'),
|
||||
onTap: () {
|
||||
Navigator.of(sheetContext).pop();
|
||||
_confirmDelete(context);
|
||||
Clipboard.setData(ClipboardData(text: post.content));
|
||||
},
|
||||
),
|
||||
],
|
||||
if (isOwn && onDelete != null)
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
LucideIcons.trash2,
|
||||
color: sheetContext.colors.error,
|
||||
),
|
||||
title: Text(
|
||||
'Delete post',
|
||||
style: TextStyle(color: sheetContext.colors.error),
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.of(sheetContext).pop();
|
||||
_confirmDelete(context);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -275,7 +279,7 @@ class ForumPostCard extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
void _confirmDelete(BuildContext context) {
|
||||
showDialog<void>(
|
||||
showBuzzDialog<void>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text('Delete post'),
|
||||
|
||||
@@ -12,6 +12,7 @@ import '../../shared/widgets/avatar_image.dart';
|
||||
import '../../shared/widgets/buzz_loading_indicator.dart';
|
||||
import '../../shared/widgets/frosted_app_bar.dart';
|
||||
import '../../shared/widgets/frosted_scaffold.dart';
|
||||
import '../../shared/widgets/modal_presentation.dart';
|
||||
import '../channels/compose_bar.dart';
|
||||
import '../channels/message_content.dart';
|
||||
import '../profile/user_cache_provider.dart';
|
||||
@@ -113,43 +114,46 @@ class ForumThreadPage extends HookConsumerWidget {
|
||||
WidgetRef ref,
|
||||
ForumThreadResponse thread,
|
||||
) {
|
||||
showModalBottomSheet<void>(
|
||||
showBuzzModalBottomSheet<void>(
|
||||
context: context,
|
||||
showDragHandle: true,
|
||||
builder: (sheetContext) => SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
Grid.gutter,
|
||||
0,
|
||||
Grid.gutter,
|
||||
Grid.xs,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(LucideIcons.copy),
|
||||
title: const Text('Copy text'),
|
||||
onTap: () {
|
||||
Navigator.of(sheetContext).pop();
|
||||
Clipboard.setData(ClipboardData(text: thread.post.content));
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
LucideIcons.trash2,
|
||||
color: sheetContext.colors.error,
|
||||
child: IconTheme.merge(
|
||||
data: const IconThemeData(size: 22),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
Grid.gutter,
|
||||
0,
|
||||
Grid.gutter,
|
||||
Grid.xs,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(LucideIcons.copy),
|
||||
title: const Text('Copy text'),
|
||||
onTap: () {
|
||||
Navigator.of(sheetContext).pop();
|
||||
Clipboard.setData(ClipboardData(text: thread.post.content));
|
||||
},
|
||||
),
|
||||
title: Text(
|
||||
'Delete post',
|
||||
style: TextStyle(color: sheetContext.colors.error),
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
LucideIcons.trash2,
|
||||
color: sheetContext.colors.error,
|
||||
),
|
||||
title: Text(
|
||||
'Delete post',
|
||||
style: TextStyle(color: sheetContext.colors.error),
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.of(sheetContext).pop();
|
||||
_confirmDeletePost(context, ref, thread.post.eventId);
|
||||
},
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.of(sheetContext).pop();
|
||||
_confirmDeletePost(context, ref, thread.post.eventId);
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -157,7 +161,7 @@ class ForumThreadPage extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
void _confirmDeletePost(BuildContext context, WidgetRef ref, String eventId) {
|
||||
showDialog<void>(
|
||||
showBuzzDialog<void>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text('Delete post'),
|
||||
@@ -533,44 +537,47 @@ class _ReplyRow extends ConsumerWidget {
|
||||
currentPubkey != null &&
|
||||
reply.pubkey.toLowerCase() == currentPubkey!.toLowerCase();
|
||||
|
||||
showModalBottomSheet<void>(
|
||||
showBuzzModalBottomSheet<void>(
|
||||
context: context,
|
||||
showDragHandle: true,
|
||||
builder: (sheetContext) => SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
Grid.gutter,
|
||||
0,
|
||||
Grid.gutter,
|
||||
Grid.xs,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(LucideIcons.copy),
|
||||
title: const Text('Copy text'),
|
||||
onTap: () {
|
||||
Navigator.of(sheetContext).pop();
|
||||
Clipboard.setData(ClipboardData(text: reply.content));
|
||||
},
|
||||
),
|
||||
if (isOwn)
|
||||
child: IconTheme.merge(
|
||||
data: const IconThemeData(size: 22),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
Grid.gutter,
|
||||
0,
|
||||
Grid.gutter,
|
||||
Grid.xs,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
LucideIcons.trash2,
|
||||
color: sheetContext.colors.error,
|
||||
),
|
||||
title: Text(
|
||||
'Delete reply',
|
||||
style: TextStyle(color: sheetContext.colors.error),
|
||||
),
|
||||
leading: const Icon(LucideIcons.copy),
|
||||
title: const Text('Copy text'),
|
||||
onTap: () {
|
||||
Navigator.of(sheetContext).pop();
|
||||
_confirmDelete(context, ref);
|
||||
Clipboard.setData(ClipboardData(text: reply.content));
|
||||
},
|
||||
),
|
||||
],
|
||||
if (isOwn)
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
LucideIcons.trash2,
|
||||
color: sheetContext.colors.error,
|
||||
),
|
||||
title: Text(
|
||||
'Delete reply',
|
||||
style: TextStyle(color: sheetContext.colors.error),
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.of(sheetContext).pop();
|
||||
_confirmDelete(context, ref);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -578,7 +585,7 @@ class _ReplyRow extends ConsumerWidget {
|
||||
}
|
||||
|
||||
void _confirmDelete(BuildContext context, WidgetRef ref) {
|
||||
showDialog<void>(
|
||||
showBuzzDialog<void>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text('Delete reply'),
|
||||
|
||||
@@ -4,11 +4,12 @@ import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import '../../shared/theme/theme.dart';
|
||||
import '../../shared/widgets/buzz_loading_indicator.dart';
|
||||
import '../../shared/widgets/modal_presentation.dart';
|
||||
import '../pairing/pairing_page.dart';
|
||||
import 'invite_join_provider.dart';
|
||||
|
||||
Future<void> showInviteJoinSheet(BuildContext context, WidgetRef ref) {
|
||||
return showModalBottomSheet<void>(
|
||||
return showBuzzModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
showDragHandle: true,
|
||||
|
||||
@@ -9,6 +9,7 @@ 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/theme/theme.dart';
|
||||
import '../../shared/widgets/modal_presentation.dart';
|
||||
import '../channels/emoji_picker.dart';
|
||||
import 'user_status.dart';
|
||||
import 'user_status_provider.dart';
|
||||
@@ -21,7 +22,7 @@ const _emojiGlyphSize = 32.0;
|
||||
const _saveButtonHeight = 52.0;
|
||||
|
||||
void showSetStatusSheet(BuildContext context, {UserStatus? currentStatus}) {
|
||||
showModalBottomSheet<void>(
|
||||
showBuzzModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
showDragHandle: true,
|
||||
|
||||
@@ -1,28 +1,40 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import '../../shared/clipboard_utils.dart';
|
||||
import '../../shared/relay/relay.dart';
|
||||
import '../../shared/theme/theme.dart';
|
||||
import '../../shared/widgets/avatar_image.dart';
|
||||
import '../../shared/utils/string_utils.dart';
|
||||
import '../../shared/widgets/avatar_image.dart';
|
||||
import '../../shared/widgets/buzz_loading_indicator.dart';
|
||||
import '../../shared/widgets/modal_presentation.dart';
|
||||
import '../channels/channel.dart';
|
||||
import '../channels/channel_detail_page.dart';
|
||||
import '../channels/channel_management_provider.dart';
|
||||
import '../channels/message_content.dart';
|
||||
import 'presence_cache_provider.dart';
|
||||
import 'user_cache_provider.dart';
|
||||
import 'user_status_cache_provider.dart';
|
||||
import '../channels/message_content.dart';
|
||||
|
||||
/// Show a user profile bottom sheet for the given [pubkey].
|
||||
void showUserProfileSheet(BuildContext context, String pubkey) {
|
||||
showModalBottomSheet<void>(
|
||||
showBuzzModalBottomSheet<Channel>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
showDragHandle: true,
|
||||
showDragHandle: false,
|
||||
builder: (_) => UserProfileSheet(pubkey: pubkey),
|
||||
);
|
||||
).then((channel) {
|
||||
if (channel == null || !context.mounted) return;
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => ChannelDetailPage(channel: channel),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
class UserProfileSheet extends HookConsumerWidget {
|
||||
@@ -66,6 +78,8 @@ class UserProfileSheet extends HookConsumerWidget {
|
||||
ref.read(userCacheProvider.notifier).preload([pk]);
|
||||
return null;
|
||||
}, [pk]);
|
||||
final copied = useState(false);
|
||||
final isOpeningDirectMessage = useState(false);
|
||||
|
||||
final displayName = profile?.displayName;
|
||||
final avatarUrl = profile?.avatarUrl;
|
||||
@@ -73,181 +87,282 @@ class UserProfileSheet extends HookConsumerWidget {
|
||||
final initial =
|
||||
profile?.initial ?? (pubkey.isNotEmpty ? pubkey[0].toUpperCase() : '?');
|
||||
|
||||
final presenceColor = switch (presence) {
|
||||
Future<void> copyPublicKey() async {
|
||||
await Clipboard.setData(ClipboardData(text: pubkey));
|
||||
if (!context.mounted) return;
|
||||
copied.value = true;
|
||||
_showProfileCopyToast(context);
|
||||
unawaited(
|
||||
Future<void>.delayed(const Duration(seconds: 2), () {
|
||||
if (context.mounted) copied.value = false;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> openDirectMessage() async {
|
||||
if (isOpeningDirectMessage.value) return;
|
||||
isOpeningDirectMessage.value = true;
|
||||
try {
|
||||
final channel = await ref
|
||||
.read(channelActionsProvider)
|
||||
.openDm(pubkeys: [pk]);
|
||||
if (!context.mounted) return;
|
||||
Navigator.of(context).pop(channel);
|
||||
} catch (_) {
|
||||
// Silently fail — user tapped but DM open failed.
|
||||
if (context.mounted) isOpeningDirectMessage.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
return ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.sizeOf(context).height * 0.7,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
Grid.gutter,
|
||||
0,
|
||||
Grid.gutter,
|
||||
MediaQuery.viewInsetsOf(context).bottom,
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.only(bottom: Grid.xs),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Center the presence chip on the avatar's lower edge.
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: Grid.xl / 2),
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.center,
|
||||
child: FractionallySizedBox(
|
||||
widthFactor: 0.5,
|
||||
child: _ProfileAvatar(
|
||||
avatarUrl: avatarUrl,
|
||||
initial: initial,
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: -(Grid.xl / 2),
|
||||
child: _ProfilePresenceChip(presence: presence),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: Grid.half),
|
||||
|
||||
// Display name — centered, large
|
||||
Center(
|
||||
child: Text(
|
||||
displayName ?? shortPubkey(pubkey),
|
||||
style: context.textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Match Settings: status is quiet, centered copy directly
|
||||
// below the profile name rather than a separate information row.
|
||||
if (userStatus != null && !userStatus.isEmpty)
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
top: Grid.xxs,
|
||||
left: Grid.gutter,
|
||||
right: Grid.gutter,
|
||||
bottom: Grid.xxs,
|
||||
),
|
||||
child: MessageContent(
|
||||
content: userStatus.text.isNotEmpty
|
||||
? '${userStatus.emoji.isNotEmpty ? '${userStatus.emoji} ' : ''}${userStatus.text}'
|
||||
: userStatus.emoji,
|
||||
baseStyle: context.textTheme.bodySmall?.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 2,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// NIP-05 handle — centered, secondary
|
||||
if (nip05 != null && nip05.isNotEmpty) ...[
|
||||
const SizedBox(height: Grid.half),
|
||||
Center(
|
||||
child: Text(
|
||||
nip05,
|
||||
style: context.textTheme.bodyMedium?.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: Grid.xs + Grid.half),
|
||||
|
||||
Row(
|
||||
children: [
|
||||
if (pk != currentPubkey) ...[
|
||||
Expanded(
|
||||
child: _ProfileActionTile(
|
||||
icon: isOpeningDirectMessage.value
|
||||
? null
|
||||
: LucideIcons.messageSquare,
|
||||
label: isOpeningDirectMessage.value
|
||||
? 'Opening…'
|
||||
: 'Message',
|
||||
isLoading: isOpeningDirectMessage.value,
|
||||
onTap: openDirectMessage,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: Grid.twelve),
|
||||
],
|
||||
Expanded(
|
||||
child: _ProfileActionTile(
|
||||
icon: copied.value
|
||||
? LucideIcons.check
|
||||
: LucideIcons.key,
|
||||
label: copied.value ? 'Copied' : 'Copy public key',
|
||||
onTap: copyPublicKey,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: Grid.xs),
|
||||
|
||||
// About / bio section
|
||||
if (about.isNotEmpty) ...[
|
||||
const SizedBox(height: Grid.xxs),
|
||||
Divider(
|
||||
color: context.colors.outlineVariant.withValues(
|
||||
alpha: 0.3,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: Grid.xxs),
|
||||
Text(
|
||||
'About',
|
||||
style: context.textTheme.labelSmall?.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: Grid.half),
|
||||
Text(
|
||||
about,
|
||||
style: context.textTheme.bodyMedium?.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows clipboard feedback above the bottom-sheet route. A regular SnackBar
|
||||
/// belongs to the page Scaffold, which sits behind the modal sheet.
|
||||
void _showProfileCopyToast(BuildContext context) {
|
||||
final overlay = Overlay.of(context, rootOverlay: true);
|
||||
final colors = context.colors;
|
||||
final textStyle = context.textTheme.bodyMedium?.copyWith(
|
||||
color: colors.onInverseSurface,
|
||||
);
|
||||
late final OverlayEntry entry;
|
||||
entry = OverlayEntry(
|
||||
builder: (overlayContext) => Positioned(
|
||||
left: Grid.gutter,
|
||||
right: Grid.gutter,
|
||||
bottom: MediaQuery.viewPaddingOf(overlayContext).bottom + Grid.xs,
|
||||
child: Material(
|
||||
color: colors.inverseSurface,
|
||||
elevation: 6,
|
||||
borderRadius: BorderRadius.circular(Radii.lg),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Grid.xs,
|
||||
vertical: Grid.twelve,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(LucideIcons.check, size: 18, color: colors.onInverseSurface),
|
||||
const SizedBox(width: Grid.xxs),
|
||||
Text('Public key copied', style: textStyle),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
overlay.insert(entry);
|
||||
Future<void>.delayed(const Duration(seconds: 2), () {
|
||||
if (entry.mounted) entry.remove();
|
||||
});
|
||||
}
|
||||
|
||||
/// The read-only version of the presence control in Settings. A viewed profile
|
||||
/// reflects its owner's availability but does not allow another user to change
|
||||
/// it.
|
||||
class _ProfilePresenceChip extends StatelessWidget {
|
||||
const _ProfilePresenceChip({required this.presence});
|
||||
|
||||
final String presence;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final effectivePresence = switch (presence) {
|
||||
'online' || 'away' => presence,
|
||||
_ => 'offline',
|
||||
};
|
||||
final backgroundColor = switch (effectivePresence) {
|
||||
'online' => context.appColors.success,
|
||||
'away' => context.appColors.warning,
|
||||
_ => context.colors.outline,
|
||||
_ => context.colors.onSurfaceVariant,
|
||||
};
|
||||
final presenceLabel = switch (presence) {
|
||||
final label = switch (effectivePresence) {
|
||||
'online' => 'Online',
|
||||
'away' => 'Away',
|
||||
_ => 'Offline',
|
||||
};
|
||||
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
Grid.sm,
|
||||
0,
|
||||
Grid.sm,
|
||||
MediaQuery.viewInsetsOf(context).bottom,
|
||||
),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.sizeOf(context).height * 0.7,
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.only(bottom: Grid.xs),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Avatar — near full-width
|
||||
_ProfileAvatar(avatarUrl: avatarUrl, initial: initial),
|
||||
const SizedBox(height: Grid.xs),
|
||||
|
||||
// Display name — centered, large
|
||||
Center(
|
||||
child: Text(
|
||||
displayName ?? shortPubkey(pubkey),
|
||||
style: context.textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
return Semantics(
|
||||
label: 'Presence: $label',
|
||||
child: SizedBox(
|
||||
height: Grid.xl,
|
||||
child: Center(
|
||||
child: Material(
|
||||
color: backgroundColor,
|
||||
borderRadius: BorderRadius.circular(Radii.full),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Grid.xs,
|
||||
vertical: Grid.xxs,
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: filterChipTextStyle.copyWith(
|
||||
color: context.colors.onPrimary,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
|
||||
// NIP-05 handle — centered, secondary
|
||||
if (nip05 != null && nip05.isNotEmpty) ...[
|
||||
const SizedBox(height: Grid.half),
|
||||
Center(
|
||||
child: Text(
|
||||
nip05,
|
||||
style: context.textTheme.bodyMedium?.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: Grid.xs),
|
||||
|
||||
// Presence row — filled dot, not an outline icon
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: Grid.half + 2),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 24,
|
||||
child: Center(
|
||||
child: Container(
|
||||
width: 10,
|
||||
height: 10,
|
||||
decoration: BoxDecoration(
|
||||
color: presenceColor,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: Grid.xxs),
|
||||
Text(
|
||||
presenceLabel,
|
||||
style: context.textTheme.bodyMedium?.copyWith(
|
||||
color: context.colors.onSurface,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
if (userStatus != null && !userStatus.isEmpty)
|
||||
_InfoRowContent(
|
||||
icon: LucideIcons.messageCircle,
|
||||
child: MessageContent(
|
||||
content:
|
||||
'${userStatus.emoji.isNotEmpty ? '${userStatus.emoji} ' : ''}${userStatus.text}',
|
||||
),
|
||||
),
|
||||
|
||||
_InfoRow(
|
||||
icon: LucideIcons.key,
|
||||
text: shortPubkey(pubkey),
|
||||
textStyle: context.textTheme.bodySmall?.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
onTap: () async {
|
||||
await copyToClipboard(
|
||||
context,
|
||||
pubkey,
|
||||
message: 'Public key copied',
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
// About / bio section
|
||||
if (about.isNotEmpty) ...[
|
||||
const SizedBox(height: Grid.xxs),
|
||||
Divider(
|
||||
color: context.colors.outlineVariant.withValues(alpha: 0.3),
|
||||
),
|
||||
const SizedBox(height: Grid.xxs),
|
||||
Text(
|
||||
'About',
|
||||
style: context.textTheme.labelSmall?.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: Grid.half),
|
||||
Text(
|
||||
about,
|
||||
style: context.textTheme.bodyMedium?.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: Grid.xs),
|
||||
|
||||
// Action button — Message (hidden on own profile)
|
||||
if (pk != currentPubkey) ...[
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
onPressed: () async {
|
||||
Navigator.of(context).pop();
|
||||
try {
|
||||
final channel = await ref
|
||||
.read(channelActionsProvider)
|
||||
.openDm(pubkeys: [pk]);
|
||||
if (!context.mounted) return;
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) =>
|
||||
ChannelDetailPage(channel: channel),
|
||||
),
|
||||
);
|
||||
} catch (_) {
|
||||
// Silently fail — user tapped but DM open failed.
|
||||
}
|
||||
},
|
||||
icon: const Icon(LucideIcons.messageSquare, size: 18),
|
||||
label: const Text('Message'),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: Grid.twelve,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(Radii.lg),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: Grid.xxs),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -256,83 +371,57 @@ class UserProfileSheet extends HookConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// A row displaying an icon + text, used for profile info items.
|
||||
|
||||
class _InfoRowContent extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final Widget child;
|
||||
|
||||
const _InfoRowContent({required this.icon, required this.child});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: Grid.quarter),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(icon, size: 16, color: context.colors.onSurfaceVariant),
|
||||
const SizedBox(width: Grid.xxs),
|
||||
Expanded(child: child),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InfoRow extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String text;
|
||||
final TextStyle? textStyle;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const _InfoRow({
|
||||
class _ProfileActionTile extends StatelessWidget {
|
||||
const _ProfileActionTile({
|
||||
required this.icon,
|
||||
required this.text,
|
||||
this.textStyle,
|
||||
this.onTap,
|
||||
required this.label,
|
||||
required this.onTap,
|
||||
this.isLoading = false,
|
||||
});
|
||||
|
||||
final IconData? icon;
|
||||
final String label;
|
||||
final VoidCallback onTap;
|
||||
final bool isLoading;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final child = Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: Grid.half + 2),
|
||||
child: Row(
|
||||
Widget build(BuildContext context) => GestureDetector(
|
||||
onTap: isLoading
|
||||
? null
|
||||
: () {
|
||||
unawaited(HapticFeedback.lightImpact());
|
||||
onTap();
|
||||
},
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
height: 68 + (Grid.xxs * 2),
|
||||
decoration: BoxDecoration(
|
||||
color: context.colors.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(Radii.dialog),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 24,
|
||||
child: Icon(icon, size: 16, color: context.colors.onSurfaceVariant),
|
||||
),
|
||||
const SizedBox(width: Grid.xxs),
|
||||
Expanded(
|
||||
child: Text(
|
||||
text,
|
||||
style:
|
||||
textStyle ??
|
||||
context.textTheme.bodyMedium?.copyWith(
|
||||
color: context.colors.onSurface,
|
||||
),
|
||||
if (isLoading)
|
||||
BuzzLoadingIndicator(
|
||||
size: 22,
|
||||
color: context.colors.onSurface,
|
||||
semanticLabel: 'Opening direct message',
|
||||
)
|
||||
else
|
||||
Icon(icon, size: 22, color: context.colors.onSurface),
|
||||
const SizedBox(height: Grid.xxs),
|
||||
Text(
|
||||
label,
|
||||
style: context.textTheme.labelMedium?.copyWith(
|
||||
color: context.colors.onSurface,
|
||||
),
|
||||
),
|
||||
if (onTap != null)
|
||||
Icon(
|
||||
LucideIcons.copy,
|
||||
size: 14,
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (onTap != null) {
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onTap,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
return child;
|
||||
}
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _ProfileAvatar extends StatelessWidget {
|
||||
@@ -345,8 +434,7 @@ class _ProfileAvatar extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: ClipOval(
|
||||
child: AvatarImageContent(
|
||||
imageUrl: avatarUrl,
|
||||
fallback: _AvatarFallback(initial: initial),
|
||||
|
||||
@@ -16,6 +16,7 @@ import '../../shared/widgets/app_list.dart';
|
||||
import '../../shared/widgets/app_list_card.dart';
|
||||
import '../../shared/widgets/frosted_app_bar.dart';
|
||||
import '../../shared/widgets/frosted_scaffold.dart';
|
||||
import '../../shared/widgets/modal_presentation.dart';
|
||||
import 'accent_picker_page.dart';
|
||||
import 'theme_picker_page.dart';
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ class _AppearanceSection extends ConsumerWidget {
|
||||
}
|
||||
|
||||
void _showAppearanceModeSheet(BuildContext context) {
|
||||
showModalBottomSheet<void>(
|
||||
showBuzzModalBottomSheet<void>(
|
||||
context: context,
|
||||
showDragHandle: true,
|
||||
builder: (_) => const _AppearanceModeSheet(),
|
||||
|
||||
@@ -73,7 +73,7 @@ class _IdentityRow extends StatelessWidget {
|
||||
}
|
||||
|
||||
void _confirmRemoveCommunity(BuildContext context, WidgetRef ref) {
|
||||
showDialog<void>(
|
||||
showBuzzDialog<void>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Remove Community'),
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import '../theme/theme.dart';
|
||||
import '../widgets/sheet_divider.dart';
|
||||
import '../widgets/modal_presentation.dart';
|
||||
import 'reminder_service.dart';
|
||||
import 'reminder_time_presets.dart';
|
||||
|
||||
@@ -37,56 +38,59 @@ void showRemindMeLaterSheet({
|
||||
}
|
||||
}
|
||||
|
||||
showModalBottomSheet<void>(
|
||||
showBuzzModalBottomSheet<void>(
|
||||
context: context,
|
||||
showDragHandle: true,
|
||||
builder: (sheetContext) => SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
Grid.gutter,
|
||||
0,
|
||||
Grid.gutter,
|
||||
Grid.xs,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: Grid.half,
|
||||
bottom: Grid.xxs,
|
||||
child: IconTheme.merge(
|
||||
data: const IconThemeData(size: 22),
|
||||
child: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
Grid.gutter,
|
||||
0,
|
||||
Grid.gutter,
|
||||
Grid.xs,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: Grid.half,
|
||||
bottom: Grid.xxs,
|
||||
),
|
||||
child: Text(
|
||||
'Remind me about this message',
|
||||
style: Theme.of(sheetContext).textTheme.titleSmall,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'Remind me about this message',
|
||||
style: Theme.of(sheetContext).textTheme.titleSmall,
|
||||
),
|
||||
),
|
||||
for (final preset in reminderTimePresets)
|
||||
for (final preset in reminderTimePresets)
|
||||
ListTile(
|
||||
leading: const Icon(LucideIcons.clock),
|
||||
title: Text(preset.label),
|
||||
onTap: () {
|
||||
Navigator.of(sheetContext).pop();
|
||||
submit(preset.getTimestamp());
|
||||
},
|
||||
),
|
||||
const SheetDivider(),
|
||||
ListTile(
|
||||
leading: const Icon(LucideIcons.clock),
|
||||
title: Text(preset.label),
|
||||
onTap: () {
|
||||
Navigator.of(sheetContext).pop();
|
||||
submit(preset.getTimestamp());
|
||||
leading: const Icon(LucideIcons.calendarClock),
|
||||
title: const Text('Pick a date & time'),
|
||||
onTap: () async {
|
||||
final navigator = Navigator.of(sheetContext);
|
||||
final timestamp = await _pickCustomDateTime(context);
|
||||
// Cancelled or not-in-the-future: keep the preset sheet
|
||||
// open so retrying doesn't mean long-pressing again.
|
||||
if (timestamp == null) return;
|
||||
if (navigator.mounted) navigator.pop();
|
||||
await submit(timestamp);
|
||||
},
|
||||
),
|
||||
const SheetDivider(),
|
||||
ListTile(
|
||||
leading: const Icon(LucideIcons.calendarClock),
|
||||
title: const Text('Pick a date & time'),
|
||||
onTap: () async {
|
||||
final navigator = Navigator.of(sheetContext);
|
||||
final timestamp = await _pickCustomDateTime(context);
|
||||
// Cancelled or not-in-the-future: keep the preset sheet
|
||||
// open so retrying doesn't mean long-pressing again.
|
||||
if (timestamp == null) return;
|
||||
if (navigator.mounted) navigator.pop();
|
||||
await submit(timestamp);
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -98,17 +102,20 @@ void showRemindMeLaterSheet({
|
||||
/// timestamp in seconds, or null when cancelled or not in the future.
|
||||
Future<int?> _pickCustomDateTime(BuildContext context) async {
|
||||
final now = DateTime.now();
|
||||
final date = await showDatePicker(
|
||||
final date = await showBuzzDialog<DateTime>(
|
||||
context: context,
|
||||
initialDate: now,
|
||||
firstDate: DateTime(now.year, now.month, now.day),
|
||||
lastDate: now.add(const Duration(days: 365 * 2)),
|
||||
builder: (_) => DatePickerDialog(
|
||||
initialDate: now,
|
||||
firstDate: DateTime(now.year, now.month, now.day),
|
||||
lastDate: now.add(const Duration(days: 365 * 2)),
|
||||
),
|
||||
);
|
||||
if (date == null || !context.mounted) return null;
|
||||
|
||||
final time = await showTimePicker(
|
||||
final time = await showBuzzDialog<TimeOfDay>(
|
||||
context: context,
|
||||
initialTime: const TimeOfDay(hour: 9, minute: 0),
|
||||
builder: (_) =>
|
||||
const TimePickerDialog(initialTime: TimeOfDay(hour: 9, minute: 0)),
|
||||
);
|
||||
if (time == null) return null;
|
||||
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
|
||||
import '../theme/theme.dart';
|
||||
|
||||
/// An iOS-native sheet surface that adopts the system's concentric corners on
|
||||
/// iOS 26 and newer. Other platforms keep the normal Flutter shape.
|
||||
class ConcentricSheetSurface extends HookWidget {
|
||||
const ConcentricSheetSurface({
|
||||
required this.child,
|
||||
required this.enabled,
|
||||
this.color,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final bool enabled;
|
||||
final Color? color;
|
||||
|
||||
static const _surfaceChannel = MethodChannel('buzz/concentric_sheet_surface');
|
||||
|
||||
Future<bool> _checkNativeSurfaceSupport() async {
|
||||
try {
|
||||
final supported = await _surfaceChannel.invokeMethod<bool>('isSupported');
|
||||
return supported == true;
|
||||
} on MissingPluginException {
|
||||
// The registrar is unavailable, so retain the Flutter surface.
|
||||
return false;
|
||||
} on PlatformException {
|
||||
// The native surface is optional; retain the Flutter surface on failure.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final shouldCheckNativeSurface =
|
||||
enabled && defaultTargetPlatform == TargetPlatform.iOS;
|
||||
final supportFuture = useMemoized(
|
||||
() => shouldCheckNativeSurface
|
||||
? _checkNativeSurfaceSupport()
|
||||
: Future<bool>.value(false),
|
||||
[shouldCheckNativeSurface],
|
||||
);
|
||||
final nativeSurfaceSupported = useFuture(supportFuture).data ?? false;
|
||||
|
||||
if (!shouldCheckNativeSurface) {
|
||||
return child;
|
||||
}
|
||||
|
||||
final surfaceColor = color ?? context.colors.surface;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: Grid.xxs,
|
||||
right: Grid.xxs,
|
||||
bottom: Grid.xxs,
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
if (nativeSurfaceSupported)
|
||||
Positioned.fill(
|
||||
child: ExcludeSemantics(
|
||||
child: UiKitView(
|
||||
viewType: 'buzz/concentric_sheet_surface',
|
||||
hitTestBehavior: PlatformViewHitTestBehavior.transparent,
|
||||
creationParams: <String, Object>{
|
||||
'color': surfaceColor.toARGB32(),
|
||||
'minimumRadius': Radii.dialog,
|
||||
},
|
||||
creationParamsCodec: const StandardMessageCodec(),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
Positioned.fill(
|
||||
child: Material(
|
||||
color: surfaceColor,
|
||||
borderRadius: BorderRadius.circular(Radii.dialog),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
),
|
||||
),
|
||||
child,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import '../theme/theme.dart';
|
||||
import 'concentric_sheet_surface.dart';
|
||||
|
||||
/// Shared motion for occasional modal UI.
|
||||
///
|
||||
/// The strong ease-out makes entrances respond immediately, while the shorter
|
||||
/// exit keeps dismissals from feeling sluggish.
|
||||
const buzzModalAnimationStyle = AnimationStyle(
|
||||
curve: Cubic(0.23, 1, 0.32, 1),
|
||||
duration: Duration(milliseconds: 280),
|
||||
reverseCurve: Cubic(0.77, 0, 0.175, 1),
|
||||
reverseDuration: Duration(milliseconds: 220),
|
||||
);
|
||||
|
||||
/// Shows a bottom sheet with Buzz's shared motion and sheet chrome.
|
||||
///
|
||||
/// Sheets include the shared close control by default. On iOS, the surface
|
||||
/// uses native concentric corners when available and paints a requested drag
|
||||
/// handle inside that inset surface; other platforms retain Flutter's handle.
|
||||
Future<T?> showBuzzModalBottomSheet<T>({
|
||||
required BuildContext context,
|
||||
required WidgetBuilder builder,
|
||||
Color? backgroundColor,
|
||||
String? barrierLabel,
|
||||
double? elevation,
|
||||
ShapeBorder? shape,
|
||||
Clip? clipBehavior,
|
||||
BoxConstraints? constraints,
|
||||
Color? barrierColor,
|
||||
bool isScrollControlled = false,
|
||||
double scrollControlDisabledMaxHeightRatio = 9.0 / 16.0,
|
||||
bool useRootNavigator = false,
|
||||
bool isDismissible = true,
|
||||
bool enableDrag = true,
|
||||
bool? showDragHandle,
|
||||
bool showCloseButton = true,
|
||||
bool useSafeArea = false,
|
||||
RouteSettings? routeSettings,
|
||||
AnimationController? transitionAnimationController,
|
||||
Offset? anchorPoint,
|
||||
AnimationStyle? sheetAnimationStyle,
|
||||
bool? requestFocus,
|
||||
}) {
|
||||
final isIos = defaultTargetPlatform == TargetPlatform.iOS;
|
||||
final theme = Theme.of(context);
|
||||
final surfaceColor =
|
||||
backgroundColor ??
|
||||
theme.bottomSheetTheme.modalBackgroundColor ??
|
||||
theme.bottomSheetTheme.backgroundColor ??
|
||||
context.colors.surface;
|
||||
final reduceMotion = MediaQuery.disableAnimationsOf(context);
|
||||
|
||||
return showModalBottomSheet<T>(
|
||||
context: context,
|
||||
builder: (sheetContext) => ConcentricSheetSurface(
|
||||
enabled: isIos,
|
||||
color: surfaceColor,
|
||||
child: _SheetContent(
|
||||
showCloseButton: showCloseButton,
|
||||
showDragHandle: isIos && showDragHandle == true,
|
||||
child: builder(sheetContext),
|
||||
),
|
||||
),
|
||||
backgroundColor: isIos ? Colors.transparent : backgroundColor,
|
||||
barrierLabel: barrierLabel,
|
||||
elevation: elevation,
|
||||
shape: shape,
|
||||
clipBehavior: clipBehavior,
|
||||
constraints: constraints,
|
||||
barrierColor: barrierColor,
|
||||
isScrollControlled: isScrollControlled,
|
||||
scrollControlDisabledMaxHeightRatio: scrollControlDisabledMaxHeightRatio,
|
||||
useRootNavigator: useRootNavigator,
|
||||
isDismissible: isDismissible,
|
||||
enableDrag: enableDrag,
|
||||
// The iOS route is transparent so its stock handle sits outside the inset
|
||||
// concentric surface. Paint it inside the surface above instead.
|
||||
showDragHandle: isIos ? false : showDragHandle,
|
||||
useSafeArea: useSafeArea,
|
||||
routeSettings: routeSettings,
|
||||
transitionAnimationController: transitionAnimationController,
|
||||
anchorPoint: anchorPoint,
|
||||
sheetAnimationStyle: reduceMotion
|
||||
? AnimationStyle.noAnimation
|
||||
: (sheetAnimationStyle ?? buzzModalAnimationStyle),
|
||||
requestFocus: requestFocus,
|
||||
);
|
||||
}
|
||||
|
||||
class _SheetContent extends StatelessWidget {
|
||||
const _SheetContent({
|
||||
required this.child,
|
||||
required this.showCloseButton,
|
||||
required this.showDragHandle,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final bool showCloseButton;
|
||||
final bool showDragHandle;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (showCloseButton)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
top: Grid.xxs,
|
||||
left: Grid.gutter,
|
||||
right: Grid.gutter,
|
||||
bottom: Grid.xs,
|
||||
),
|
||||
child: SizedBox(
|
||||
height: 56,
|
||||
child: Stack(
|
||||
alignment: Alignment.topCenter,
|
||||
children: [
|
||||
if (showDragHandle) const _SheetDragHandle(),
|
||||
Align(
|
||||
alignment: Alignment.bottomRight,
|
||||
child: SizedBox.square(
|
||||
dimension: 44,
|
||||
child: IconButton(
|
||||
tooltip: 'Close sheet',
|
||||
onPressed: () {
|
||||
unawaited(HapticFeedback.lightImpact());
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
style: IconButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
backgroundColor:
|
||||
context.colors.surfaceContainerHighest,
|
||||
foregroundColor: context.colors.onSurface,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(Radii.dialog),
|
||||
),
|
||||
),
|
||||
icon: const Icon(LucideIcons.x, size: 22),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (showDragHandle)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: Grid.xxs, bottom: Grid.xs),
|
||||
child: _SheetDragHandle(),
|
||||
),
|
||||
Flexible(child: child),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SheetDragHandle extends StatelessWidget {
|
||||
const _SheetDragHandle();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Semantics(
|
||||
label: 'Drag handle',
|
||||
child: Container(
|
||||
key: const ValueKey('buzz-sheet-drag-handle'),
|
||||
width: 32,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: context.colors.onSurfaceVariant.withValues(alpha: 0.4),
|
||||
borderRadius: BorderRadius.circular(Radii.full),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows a dialog with Buzz's shared motion, respecting reduced-motion settings.
|
||||
Future<T?> showBuzzDialog<T>({
|
||||
required BuildContext context,
|
||||
required WidgetBuilder builder,
|
||||
bool barrierDismissible = true,
|
||||
Color? barrierColor,
|
||||
String? barrierLabel,
|
||||
bool useSafeArea = true,
|
||||
bool useRootNavigator = true,
|
||||
RouteSettings? routeSettings,
|
||||
Offset? anchorPoint,
|
||||
TraversalEdgeBehavior? traversalEdgeBehavior,
|
||||
bool fullscreenDialog = false,
|
||||
bool? requestFocus,
|
||||
AnimationStyle? animationStyle,
|
||||
}) {
|
||||
final reduceMotion = MediaQuery.disableAnimationsOf(context);
|
||||
|
||||
return showDialog<T>(
|
||||
context: context,
|
||||
builder: builder,
|
||||
barrierDismissible: barrierDismissible,
|
||||
barrierColor: barrierColor,
|
||||
barrierLabel: barrierLabel,
|
||||
useSafeArea: useSafeArea,
|
||||
useRootNavigator: useRootNavigator,
|
||||
routeSettings: routeSettings,
|
||||
anchorPoint: anchorPoint,
|
||||
traversalEdgeBehavior: traversalEdgeBehavior,
|
||||
fullscreenDialog: fullscreenDialog,
|
||||
requestFocus: requestFocus,
|
||||
animationStyle: reduceMotion
|
||||
? AnimationStyle.noAnimation
|
||||
: (animationStyle ?? buzzModalAnimationStyle),
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart' show ScrollDirection;
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
@@ -27,6 +28,7 @@ import 'package:buzz/features/channels/small_avatar.dart';
|
||||
import 'package:buzz/features/profile/profile_provider.dart';
|
||||
import 'package:buzz/features/profile/user_cache_provider.dart';
|
||||
import 'package:buzz/features/profile/user_profile.dart';
|
||||
import 'package:buzz/features/profile/user_profile_sheet.dart';
|
||||
import 'package:buzz/shared/mentions/agent_identity_provider.dart';
|
||||
import 'package:buzz/shared/relay/relay.dart';
|
||||
import 'package:buzz/shared/theme/theme.dart';
|
||||
@@ -2254,6 +2256,82 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'keeps membership and huddle rows evenly spaced with authored messages',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(
|
||||
_buildTestable(
|
||||
messages: [
|
||||
_textMsg(
|
||||
id: 'message-alice',
|
||||
pubkey: 'alice',
|
||||
content: 'First message',
|
||||
createdAt: 1000,
|
||||
),
|
||||
_textMsg(
|
||||
id: 'message-bob',
|
||||
pubkey: 'bob',
|
||||
content: 'Second message',
|
||||
createdAt: 1010,
|
||||
),
|
||||
_systemMsg(
|
||||
id: 'membership-carol',
|
||||
payload: {
|
||||
'type': 'member_joined',
|
||||
'actor': 'alice',
|
||||
'target': 'carol',
|
||||
},
|
||||
createdAt: 1020,
|
||||
),
|
||||
_huddleMsg(
|
||||
id: 'huddle-dave',
|
||||
kind: EventKind.huddleStarted,
|
||||
pubkey: 'dave',
|
||||
createdAt: 1030,
|
||||
),
|
||||
_textMsg(
|
||||
id: 'message-erin',
|
||||
pubkey: 'erin',
|
||||
content: 'Third message',
|
||||
createdAt: 1040,
|
||||
),
|
||||
],
|
||||
users: {
|
||||
for (final name in ['alice', 'bob', 'carol', 'dave', 'erin'])
|
||||
name: UserProfile(pubkey: name, displayName: name),
|
||||
},
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
Rect avatarRect(String rowKey) => tester.getRect(
|
||||
find
|
||||
.descendant(
|
||||
of: find.byKey(ValueKey(rowKey)),
|
||||
matching: find.byType(CircleAvatar),
|
||||
)
|
||||
.first,
|
||||
);
|
||||
|
||||
final avatars = [
|
||||
avatarRect('message-row-message-alice'),
|
||||
avatarRect('message-row-message-bob'),
|
||||
avatarRect('system-message-row-membership-carol'),
|
||||
avatarRect('system-message-row-huddle-dave'),
|
||||
avatarRect('message-row-message-erin'),
|
||||
];
|
||||
final authoredMessageGap = avatars[1].top - avatars[0].bottom;
|
||||
|
||||
for (var index = 2; index < avatars.length; index++) {
|
||||
expect(
|
||||
avatars[index].top - avatars[index - 1].bottom,
|
||||
closeTo(authoredMessageGap, 1),
|
||||
reason: 'row $index should use the authored-message gap',
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('renders member_joined (self-join) system event', (
|
||||
tester,
|
||||
) async {
|
||||
@@ -2280,6 +2358,118 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('opens a profile sheet from a membership system avatar', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
_buildTestable(
|
||||
messages: [
|
||||
_systemMsg(
|
||||
id: 'sys-membership-avatar',
|
||||
payload: {
|
||||
'type': 'member_joined',
|
||||
'actor': 'alice',
|
||||
'target': 'bob',
|
||||
},
|
||||
),
|
||||
],
|
||||
users: {
|
||||
'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'),
|
||||
'bob': const UserProfile(pubkey: 'bob', displayName: 'Bob'),
|
||||
},
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byType(CircleAvatar));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Copy public key'), findsOneWidget);
|
||||
expect(find.text('alice'), findsNothing);
|
||||
expect(find.byType(UserProfileSheet), findsOneWidget);
|
||||
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(SystemChannels.platform, (_) async => null);
|
||||
addTearDown(
|
||||
() => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(SystemChannels.platform, null),
|
||||
);
|
||||
await tester.ensureVisible(find.text('Copy public key'));
|
||||
await tester.pumpAndSettle();
|
||||
final copyAction = find
|
||||
.ancestor(
|
||||
of: find.text('Copy public key'),
|
||||
matching: find.byType(GestureDetector),
|
||||
)
|
||||
.last;
|
||||
tester.widget<GestureDetector>(copyAction).onTap!();
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
expect(find.text('Public key copied'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byTooltip('Close sheet'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.pump(const Duration(seconds: 2));
|
||||
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('opens a profile sheet from a huddle system avatar', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
_buildTestable(
|
||||
messages: [
|
||||
_huddleMsg(
|
||||
id: 'sys-huddle-avatar',
|
||||
kind: EventKind.huddleStarted,
|
||||
pubkey: 'alice',
|
||||
),
|
||||
],
|
||||
users: {
|
||||
'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'),
|
||||
},
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byType(CircleAvatar));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Copy public key'), findsOneWidget);
|
||||
expect(find.byType(UserProfileSheet), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('opens a profile sheet from a generic system avatar', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
_buildTestable(
|
||||
messages: [
|
||||
_systemMsg(
|
||||
id: 'sys-removed-avatar',
|
||||
payload: {
|
||||
'type': 'member_removed',
|
||||
'actor': 'alice',
|
||||
'target': 'bob',
|
||||
},
|
||||
),
|
||||
],
|
||||
users: {
|
||||
'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'),
|
||||
'bob': const UserProfile(pubkey: 'bob', displayName: 'Bob'),
|
||||
},
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byType(CircleAvatar).first);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Copy public key'), findsOneWidget);
|
||||
expect(find.byType(UserProfileSheet), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('renders member_joined (added by other) system event', (
|
||||
tester,
|
||||
) async {
|
||||
|
||||
@@ -409,8 +409,8 @@ void main() {
|
||||
final row = quickReactionEmoji(entries, customShortcodes: const {});
|
||||
|
||||
expect(row.first, '\u{1F525}');
|
||||
expect(row, hasLength(4));
|
||||
expect(row, containsAll(defaultQuickEmojis.take(3)));
|
||||
expect(row, hasLength(5));
|
||||
expect(row, containsAll(defaultQuickEmojis.take(4)));
|
||||
});
|
||||
|
||||
test('drops custom emoji no longer in the palette', () {
|
||||
|
||||
@@ -196,6 +196,22 @@ void main() {
|
||||
expect(find.text('Remind me'), findsNothing);
|
||||
expect(find.text('Edit message'), findsNothing);
|
||||
expect(find.text('Delete message'), findsNothing);
|
||||
expect(find.byTooltip('Close sheet'), findsNothing);
|
||||
expect(find.byKey(const ValueKey('quick-reaction-more')), findsOneWidget);
|
||||
expect(
|
||||
find.byWidgetPredicate(
|
||||
(widget) =>
|
||||
widget.key is ValueKey<String> &&
|
||||
(widget.key! as ValueKey<String>).value.startsWith(
|
||||
'quick-reaction-',
|
||||
),
|
||||
),
|
||||
findsNWidgets(6),
|
||||
);
|
||||
expect(
|
||||
tester.getSize(find.byKey(const ValueKey('quick-reaction-\u{1F44D}'))),
|
||||
const Size.square(52),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('promotes Reply, Copy link, and Remind me to the fast-actions '
|
||||
@@ -227,6 +243,47 @@ void main() {
|
||||
expect(find.text('Follow thread'), findsNothing);
|
||||
expect(find.text('Reply'), findsNothing);
|
||||
expect(find.text('Remind me'), findsNothing);
|
||||
expect(find.byTooltip('Close sheet'), findsNothing);
|
||||
expect(
|
||||
find.byWidgetPredicate(
|
||||
(widget) =>
|
||||
widget.key is ValueKey<String> &&
|
||||
(widget.key! as ValueKey<String>).value.startsWith(
|
||||
'quick-reaction-',
|
||||
),
|
||||
),
|
||||
findsNWidgets(6),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('keeps six reaction targets within a narrow phone', (
|
||||
tester,
|
||||
) async {
|
||||
tester.view.physicalSize = const Size(375, 800);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
final prefs = await _mockPrefs();
|
||||
|
||||
await _pumpSheet(tester, message: _message(), prefs: prefs);
|
||||
|
||||
expect(
|
||||
find.byWidgetPredicate(
|
||||
(widget) =>
|
||||
widget.key is ValueKey<String> &&
|
||||
(widget.key! as ValueKey<String>).value.startsWith(
|
||||
'quick-reaction-',
|
||||
),
|
||||
),
|
||||
findsNWidgets(6),
|
||||
);
|
||||
expect(
|
||||
tester
|
||||
.getSize(find.byKey(const ValueKey('quick-reaction-\u{1F44D}')))
|
||||
.width,
|
||||
inInclusiveRange(44, 52),
|
||||
);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('shows Edit/Delete only with manage rights', (tester) async {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:gpt_markdown/gpt_markdown.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:hooks_riverpod/misc.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
@@ -155,6 +156,24 @@ bool _spanHasStyle(
|
||||
|
||||
void main() {
|
||||
group('MessageContent', () {
|
||||
testWidgets('forwards text alignment to markdown rendering', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
_testable(
|
||||
const MessageContent(
|
||||
content: 'Centered status',
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
tester.widget<GptMarkdown>(find.byType(GptMarkdown)).textAlign,
|
||||
TextAlign.center,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('opens local file links through an authenticated download', (
|
||||
tester,
|
||||
) async {
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
import 'package:buzz/shared/widgets/concentric_sheet_surface.dart';
|
||||
import 'package:buzz/shared/theme/theme.dart';
|
||||
import 'package:buzz/shared/widgets/modal_presentation.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets(
|
||||
'keeps an opaque Flutter surface when iOS native support is unavailable',
|
||||
(tester) async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
try {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
home: const ConcentricSheetSurface(
|
||||
enabled: true,
|
||||
color: Colors.red,
|
||||
child: SizedBox(height: 80, child: Text('Sheet body')),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.byWidgetPredicate(
|
||||
(widget) => widget is Material && widget.color == Colors.red,
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
} finally {
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'replaces the Flutter fallback when native support is available',
|
||||
(tester) async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
const surfaceChannel = MethodChannel('buzz/concentric_sheet_surface');
|
||||
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
surfaceChannel,
|
||||
(call) async => call.method == 'isSupported' ? true : null,
|
||||
);
|
||||
try {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
home: const ConcentricSheetSurface(
|
||||
enabled: true,
|
||||
color: Colors.red,
|
||||
child: SizedBox(height: 80, child: Text('Sheet body')),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byType(UiKitView), findsOneWidget);
|
||||
expect(
|
||||
find.byWidgetPredicate(
|
||||
(widget) => widget is Material && widget.color == Colors.red,
|
||||
),
|
||||
findsNothing,
|
||||
);
|
||||
} finally {
|
||||
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
surfaceChannel,
|
||||
null,
|
||||
);
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'non-iOS sheets use Flutter drag handle and shared close control',
|
||||
(tester) async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
||||
try {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
home: Scaffold(
|
||||
body: Builder(
|
||||
builder: (context) => FilledButton(
|
||||
onPressed: () => showBuzzModalBottomSheet<void>(
|
||||
context: context,
|
||||
showDragHandle: true,
|
||||
builder: (_) => const Text('Sheet body'),
|
||||
),
|
||||
child: const Text('Open sheet'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Open sheet'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final closeButton = find.byTooltip('Close sheet');
|
||||
expect(closeButton, findsOneWidget);
|
||||
expect(tester.getSize(closeButton), const Size.square(44));
|
||||
final closeGutter = find.ancestor(
|
||||
of: closeButton,
|
||||
matching: find.byWidgetPredicate(
|
||||
(widget) =>
|
||||
widget is Padding &&
|
||||
widget.padding ==
|
||||
const EdgeInsets.only(
|
||||
top: Grid.xxs,
|
||||
left: Grid.gutter,
|
||||
right: Grid.gutter,
|
||||
bottom: Grid.xs,
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(closeGutter, findsOneWidget);
|
||||
final gutterRect = tester.getRect(closeGutter);
|
||||
final closeRect = tester.getRect(closeButton);
|
||||
expect(closeRect.top - gutterRect.top, Grid.gutter);
|
||||
expect(gutterRect.right - closeRect.right, Grid.gutter);
|
||||
expect(
|
||||
tester.widget<BottomSheet>(find.byType(BottomSheet)).showDragHandle,
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
find.byKey(const ValueKey('buzz-sheet-drag-handle')),
|
||||
findsNothing,
|
||||
);
|
||||
expect(find.text('Sheet body'), findsOneWidget);
|
||||
|
||||
await tester.tap(closeButton);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Sheet body'), findsNothing);
|
||||
} finally {
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('iOS paints the drag handle inside the concentric surface', (
|
||||
tester,
|
||||
) async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
try {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
home: Scaffold(
|
||||
body: Builder(
|
||||
builder: (context) => FilledButton(
|
||||
onPressed: () => showBuzzModalBottomSheet<void>(
|
||||
context: context,
|
||||
showDragHandle: true,
|
||||
builder: (_) => const Text('Sheet body'),
|
||||
),
|
||||
child: const Text('Open sheet'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Open sheet'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
tester.widget<BottomSheet>(find.byType(BottomSheet)).showDragHandle,
|
||||
isFalse,
|
||||
);
|
||||
final internalHandle = find.byKey(
|
||||
const ValueKey('buzz-sheet-drag-handle'),
|
||||
);
|
||||
expect(internalHandle, findsOneWidget);
|
||||
expect(tester.getSize(internalHandle), const Size(32, 4));
|
||||
expect(find.bySemanticsLabel('Drag handle'), findsOneWidget);
|
||||
expect(find.byTooltip('Close sheet'), findsOneWidget);
|
||||
expect(find.text('Sheet body'), findsOneWidget);
|
||||
} finally {
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('iOS compact sheets can omit X but retain the inside handle', (
|
||||
tester,
|
||||
) async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
try {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
home: Scaffold(
|
||||
body: Builder(
|
||||
builder: (context) => FilledButton(
|
||||
onPressed: () => showBuzzModalBottomSheet<void>(
|
||||
context: context,
|
||||
showDragHandle: true,
|
||||
showCloseButton: false,
|
||||
builder: (_) => const Text('Compact sheet body'),
|
||||
),
|
||||
child: const Text('Open compact sheet'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Open compact sheet'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.byKey(const ValueKey('buzz-sheet-drag-handle')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.byTooltip('Close sheet'), findsNothing);
|
||||
expect(find.text('Compact sheet body'), findsOneWidget);
|
||||
} finally {
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user