feat: import Chinese-localized Buzz source snapshot
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Signed-off-by: cls_宁波本机 <908705107@qq.com>
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
part of '../channels_page.dart';
|
||||
|
||||
class _EphemeralBadge extends StatelessWidget {
|
||||
final Channel channel;
|
||||
|
||||
const _EphemeralBadge({required this.channel});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final display = ephemeralChannelDisplay(channel, l10n: context.l10n);
|
||||
if (display == null) return const SizedBox.shrink();
|
||||
|
||||
return Tooltip(
|
||||
message: display.tooltipLabel,
|
||||
child: Icon(
|
||||
LucideIcons.clockFading,
|
||||
key: Key('channel-ephemeral-${channel.id}'),
|
||||
size: 16,
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ErrorView extends StatelessWidget {
|
||||
final Object error;
|
||||
final VoidCallback onRetry;
|
||||
|
||||
const _ErrorView({required this.error, required this.onRetry});
|
||||
|
||||
static String _userMessage(BuildContext context, Object error) {
|
||||
final l10n = context.l10n;
|
||||
if (error is RelayException) {
|
||||
if (error.statusCode == 401) {
|
||||
return l10n.notAuthorizedApi;
|
||||
}
|
||||
if (error.statusCode == 403) {
|
||||
return l10n.accessDenied;
|
||||
}
|
||||
return l10n.serverErrorRetry('${error.statusCode}');
|
||||
}
|
||||
if (error is SocketException) {
|
||||
return l10n.relayUnreachable;
|
||||
}
|
||||
return l10n.connectionSomethingWrong;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(Grid.sm),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.wifiOff,
|
||||
size: Grid.xl,
|
||||
color: context.colors.error,
|
||||
),
|
||||
const SizedBox(height: Grid.xs),
|
||||
Text(
|
||||
context.l10n.channelsLoadFailed,
|
||||
style: context.textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: Grid.xxs),
|
||||
Text(
|
||||
_userMessage(context, error),
|
||||
style: context.textTheme.bodySmall?.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: Grid.xs),
|
||||
FilledButton.icon(
|
||||
onPressed: onRetry,
|
||||
icon: const Icon(LucideIcons.refreshCw),
|
||||
label: Text(context.l10n.retry),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
part of '../channels_page.dart';
|
||||
|
||||
class _ChannelsBody extends StatelessWidget {
|
||||
final List<Channel>? channels;
|
||||
final AsyncValue<List<Channel>> channelsAsync;
|
||||
final bool showError;
|
||||
final SessionStatus sessionStatus;
|
||||
final bool showConnectionSkeleton;
|
||||
final String? currentPubkey;
|
||||
final double topSectionHeight;
|
||||
final bool usesPinnedGradient;
|
||||
final ScrollController scrollController;
|
||||
final Future<void> Function() onRefresh;
|
||||
final Future<void> Function(Channel channel) onSelectChannel;
|
||||
|
||||
const _ChannelsBody({
|
||||
required this.channels,
|
||||
required this.channelsAsync,
|
||||
required this.showError,
|
||||
required this.sessionStatus,
|
||||
required this.showConnectionSkeleton,
|
||||
required this.currentPubkey,
|
||||
required this.topSectionHeight,
|
||||
required this.usesPinnedGradient,
|
||||
required this.scrollController,
|
||||
required this.onRefresh,
|
||||
required this.onSelectChannel,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final barHeight = topSectionHeight;
|
||||
final loadedChannels = channels;
|
||||
final loading =
|
||||
showConnectionSkeleton || (loadedChannels == null && !showError);
|
||||
final content = showError && channelsAsync.hasError
|
||||
? Padding(
|
||||
padding: EdgeInsets.only(top: barHeight),
|
||||
child: _ErrorView(error: channelsAsync.error!, onRetry: onRefresh),
|
||||
)
|
||||
: loadedChannels == null
|
||||
? const SizedBox.shrink()
|
||||
: BeeRefreshIndicator(
|
||||
edgeOffset: barHeight,
|
||||
onRefresh: onRefresh,
|
||||
child: CustomScrollView(
|
||||
controller: scrollController,
|
||||
// The transparent gap shows the top section and must not absorb
|
||||
// taps meant for the community or profile controls beneath it.
|
||||
hitTestBehavior: HitTestBehavior.deferToChild,
|
||||
slivers: [
|
||||
SliverToBoxAdapter(child: SizedBox(height: barHeight)),
|
||||
if (usesPinnedGradient)
|
||||
_SliverChannelsList(
|
||||
channels: loadedChannels,
|
||||
currentPubkey: currentPubkey,
|
||||
onSelectChannel: onSelectChannel,
|
||||
)
|
||||
else
|
||||
DecoratedSliver(
|
||||
decoration: BoxDecoration(
|
||||
color: context.colors.surface,
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(Radii.dialog),
|
||||
),
|
||||
),
|
||||
sliver: _SliverChannelsList(
|
||||
channels: loadedChannels,
|
||||
currentPubkey: currentPubkey,
|
||||
onSelectChannel: onSelectChannel,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
return SkeletonReveal(
|
||||
loading: loading,
|
||||
shimmerEnabled: sessionStatus != SessionStatus.disconnected,
|
||||
skeleton: _ChannelsSkeleton(
|
||||
channels: loadedChannels,
|
||||
topInset: barHeight,
|
||||
status: sessionStatus,
|
||||
),
|
||||
content: content,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SliverChannelsList extends HookConsumerWidget {
|
||||
final List<Channel> channels;
|
||||
final String? currentPubkey;
|
||||
final Future<void> Function(Channel channel) onSelectChannel;
|
||||
|
||||
const _SliverChannelsList({
|
||||
required this.channels,
|
||||
required this.currentPubkey,
|
||||
required this.onSelectChannel,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final readState = ref.watch(readStateProvider);
|
||||
final sectionsState = ref.watch(channelSectionsProvider);
|
||||
final mutesState = ref.watch(channelMutesProvider);
|
||||
final mutedChannelIds = {
|
||||
for (final entry in mutesState.store.channels.entries)
|
||||
if (entry.value.muted) entry.key,
|
||||
};
|
||||
final starsState = ref.watch(channelStarsProvider);
|
||||
final starredChannelIds = {
|
||||
for (final entry in starsState.store.channels.entries)
|
||||
if (entry.value.starred) entry.key,
|
||||
};
|
||||
final visibleChannels = channels
|
||||
.where((channel) => channel.isMember && !channel.isArchived)
|
||||
.toList();
|
||||
final streamChannels = visibleChannels
|
||||
.where((channel) => channel.isStream)
|
||||
.toList();
|
||||
final dmChannels = sortDmChannelsByDisplayLabel(
|
||||
visibleChannels.where((channel) => channel.isDm),
|
||||
currentPubkey: currentPubkey,
|
||||
);
|
||||
|
||||
final starredExpanded = useState(true);
|
||||
final channelsExpanded = useState(true);
|
||||
final dmsExpanded = useState(true);
|
||||
final sortState = ref.watch(channelSortProvider);
|
||||
final initialSeedComplete = useState(false);
|
||||
final seededPubkey = useRef<String?>(null);
|
||||
final seedCompleteForPubkey =
|
||||
seededPubkey.value == readState.pubkey && initialSeedComplete.value;
|
||||
|
||||
useEffect(() {
|
||||
if (!readState.isReady) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return deferReadStateUpdate(context, () {
|
||||
if (seededPubkey.value != readState.pubkey) {
|
||||
seededPubkey.value = readState.pubkey;
|
||||
initialSeedComplete.value = false;
|
||||
}
|
||||
|
||||
if (initialSeedComplete.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
final notifier = ref.read(readStateProvider.notifier);
|
||||
for (final channel in visibleChannels) {
|
||||
if (readState.effectiveTimestamp(channel.id) != null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final lastMessageAt = dateTimeToUnixSeconds(channel.lastMessageAt);
|
||||
if (lastMessageAt != null) {
|
||||
notifier.seedContextRead(channel.id, lastMessageAt);
|
||||
}
|
||||
}
|
||||
initialSeedComplete.value = true;
|
||||
});
|
||||
}, [readState.isReady, readState.pubkey, visibleChannels]);
|
||||
|
||||
final unreadState = _computeUnreadChannelState(
|
||||
channels: visibleChannels,
|
||||
readState: readState,
|
||||
channelsNotifier: ref.read(channelsProvider.notifier),
|
||||
);
|
||||
final unreadChannelIds = {
|
||||
for (final channelId in unreadState.ids)
|
||||
if (seedCompleteForPubkey ||
|
||||
readState.effectiveTimestamp(channelId) != null)
|
||||
channelId,
|
||||
};
|
||||
// Build sorted user-defined sections and compute which stream channels
|
||||
// belong to each section. Channels not assigned to any valid section fall
|
||||
// through to the built-in "Channels" list.
|
||||
final userSections = sectionsState.store.sections.toList()
|
||||
..sort((a, b) => a.order.compareTo(b.order));
|
||||
final sectionAssignments = sectionsState.store.assignments;
|
||||
final validSectionIds = {for (final s in userSections) s.id};
|
||||
final assignedChannelIds = {
|
||||
for (final entry in sectionAssignments.entries)
|
||||
if (validSectionIds.contains(entry.value)) entry.key,
|
||||
};
|
||||
// Starred is exclusive: a starred channel lives only in the Starred section,
|
||||
// not in its custom section or the default Channels list.
|
||||
final starredStreamChannels = sortChannelsForList(
|
||||
streamChannels.where((c) => starredChannelIds.contains(c.id)).toList(),
|
||||
sortState.sortModeFor('starred'),
|
||||
);
|
||||
final ungroupedStreamChannels = sortChannelsForList(
|
||||
streamChannels
|
||||
.where(
|
||||
(c) =>
|
||||
!assignedChannelIds.contains(c.id) &&
|
||||
!starredChannelIds.contains(c.id),
|
||||
)
|
||||
.toList(),
|
||||
sortState.sortModeFor('channels'),
|
||||
);
|
||||
// DMs default to the display-label alphabetical order (labels can differ
|
||||
// from channel names); Recent mode reorders by last message time.
|
||||
final sortedDmChannels =
|
||||
sortState.sortModeFor('dms') == ChannelSortMode.recent
|
||||
? sortChannelsForList(dmChannels, ChannelSortMode.recent)
|
||||
: dmChannels;
|
||||
|
||||
final liveSectionIds = [for (final s in userSections) s.id];
|
||||
void setSortMode(String groupKey, ChannelSortMode mode) {
|
||||
ref
|
||||
.read(channelSortProvider.notifier)
|
||||
.setSortModeFor(groupKey, mode, liveSectionIds: liveSectionIds);
|
||||
}
|
||||
|
||||
final sectionExpandedStates = useState<Map<String, bool>>({});
|
||||
|
||||
bool sectionExpanded(String sectionId) =>
|
||||
sectionExpandedStates.value[sectionId] ?? true;
|
||||
|
||||
void toggleSection(String sectionId) {
|
||||
sectionExpandedStates.value = {
|
||||
...sectionExpandedStates.value,
|
||||
sectionId: !sectionExpanded(sectionId),
|
||||
};
|
||||
}
|
||||
|
||||
return SliverPadding(
|
||||
padding: EdgeInsets.only(
|
||||
top: Grid.xxs,
|
||||
bottom: MediaQuery.paddingOf(context).bottom,
|
||||
),
|
||||
sliver: SliverList.list(
|
||||
children: [
|
||||
if (visibleChannels.isEmpty)
|
||||
const _EmptyState()
|
||||
else ...[
|
||||
// Starred channels (exclusive — pinned above all sections).
|
||||
if (starredStreamChannels.isNotEmpty)
|
||||
_ChannelSection(
|
||||
title: context.l10n.starred,
|
||||
icon: LucideIcons.star,
|
||||
showTopDivider: false,
|
||||
expanded: starredExpanded.value,
|
||||
onToggle: () => starredExpanded.value = !starredExpanded.value,
|
||||
channels: starredStreamChannels,
|
||||
unreadChannelIds: unreadChannelIds,
|
||||
mutedChannelIds: mutedChannelIds,
|
||||
currentPubkey: currentPubkey,
|
||||
emptyLabel: '',
|
||||
sortMode: sortState.sortModeFor('starred'),
|
||||
onSortModeChange: (mode) => setSortMode('starred', mode),
|
||||
onSelectChannel: onSelectChannel,
|
||||
),
|
||||
// User-defined sections for stream channels, in user-defined order.
|
||||
for (final section in userSections)
|
||||
_CustomChannelSection(
|
||||
section: section,
|
||||
channels: sortChannelsForList(
|
||||
streamChannels
|
||||
.where(
|
||||
(c) =>
|
||||
sectionAssignments[c.id] == section.id &&
|
||||
!starredChannelIds.contains(c.id),
|
||||
)
|
||||
.toList(),
|
||||
sortState.sortModeFor(sectionSortGroupKey(section.id)),
|
||||
),
|
||||
unreadChannelIds: unreadChannelIds,
|
||||
mutedChannelIds: mutedChannelIds,
|
||||
currentPubkey: currentPubkey,
|
||||
expanded: sectionExpanded(section.id),
|
||||
isFirst: userSections.first.id == section.id,
|
||||
isLast: userSections.last.id == section.id,
|
||||
showTopDivider:
|
||||
starredStreamChannels.isNotEmpty ||
|
||||
userSections.first.id != section.id,
|
||||
onToggle: () => toggleSection(section.id),
|
||||
onRename: () async {
|
||||
final name = await showBuzzDialog<String>(
|
||||
context: context,
|
||||
builder: (_) => _SectionNameDialog(
|
||||
title: context.l10n.renameSection,
|
||||
confirmLabel: context.l10n.rename,
|
||||
initialValue: section.name,
|
||||
),
|
||||
);
|
||||
if (name != null && name.isNotEmpty) {
|
||||
ref
|
||||
.read(channelSectionsProvider.notifier)
|
||||
.renameSection(section.id, name);
|
||||
}
|
||||
},
|
||||
onDelete: () async {
|
||||
final confirmed = await showBuzzDialog<bool>(
|
||||
context: context,
|
||||
builder: (_) => AlertDialog(
|
||||
title: Text(context.l10n.deleteSectionNamed(section.name)),
|
||||
content: Text(context.l10n.deleteSectionConfirm),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: Text(context.l10n.cancel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: Text(
|
||||
context.l10n.delete,
|
||||
style: TextStyle(color: context.colors.error),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed == true) {
|
||||
ref
|
||||
.read(channelSectionsProvider.notifier)
|
||||
.deleteSection(section.id);
|
||||
}
|
||||
},
|
||||
onMoveUp: () => ref
|
||||
.read(channelSectionsProvider.notifier)
|
||||
.moveSectionUp(section.id),
|
||||
onMoveDown: () => ref
|
||||
.read(channelSectionsProvider.notifier)
|
||||
.moveSectionDown(section.id),
|
||||
sortMode: sortState.sortModeFor(
|
||||
sectionSortGroupKey(section.id),
|
||||
),
|
||||
onSortModeChange: (mode) =>
|
||||
setSortMode(sectionSortGroupKey(section.id), mode),
|
||||
onSelectChannel: onSelectChannel,
|
||||
onMarkChannelRead: (channel) {
|
||||
final ts = dateTimeToUnixSeconds(channel.lastMessageAt);
|
||||
if (ts != null) {
|
||||
ref
|
||||
.read(readStateProvider.notifier)
|
||||
.markContextRead(
|
||||
channel.id,
|
||||
ts,
|
||||
clearForcedMessages: true,
|
||||
);
|
||||
ref
|
||||
.read(channelsProvider.notifier)
|
||||
.clearObservedUnreadCoveredByRead(channel.id, ts);
|
||||
}
|
||||
},
|
||||
),
|
||||
_ChannelSection(
|
||||
title: context.l10n.searchChannels,
|
||||
icon: LucideIcons.hash,
|
||||
showTopDivider:
|
||||
starredStreamChannels.isNotEmpty || userSections.isNotEmpty,
|
||||
expanded: channelsExpanded.value,
|
||||
onToggle: () => channelsExpanded.value = !channelsExpanded.value,
|
||||
channels: ungroupedStreamChannels,
|
||||
unreadChannelIds: unreadChannelIds,
|
||||
mutedChannelIds: mutedChannelIds,
|
||||
currentPubkey: currentPubkey,
|
||||
emptyLabel: context.l10n.noStreamChannels,
|
||||
sortMode: sortState.sortModeFor('channels'),
|
||||
onSortModeChange: (mode) => setSortMode('channels', mode),
|
||||
onSelectChannel: onSelectChannel,
|
||||
),
|
||||
_ChannelSection(
|
||||
title: context.l10n.directMessages,
|
||||
icon: LucideIcons.messagesSquare,
|
||||
showTopDivider: true,
|
||||
expanded: dmsExpanded.value,
|
||||
onToggle: () => dmsExpanded.value = !dmsExpanded.value,
|
||||
channels: sortedDmChannels,
|
||||
unreadChannelIds: unreadChannelIds,
|
||||
mutedChannelIds: mutedChannelIds,
|
||||
currentPubkey: currentPubkey,
|
||||
emptyLabel: context.l10n.noDirectMessages,
|
||||
sortMode: sortState.sortModeFor('dms'),
|
||||
onSortModeChange: (mode) => setSortMode('dms', mode),
|
||||
onSelectChannel: onSelectChannel,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
part of '../channels_page.dart';
|
||||
|
||||
class _ChannelTile extends ConsumerWidget {
|
||||
final Channel channel;
|
||||
final bool isUnread;
|
||||
final bool isMuted;
|
||||
final String? currentPubkey;
|
||||
final VoidCallback onTap;
|
||||
|
||||
/// Called when the user requests to mark this channel read (from long-press
|
||||
/// actions menu). Null for channels in built-in sections.
|
||||
final VoidCallback? onMarkRead;
|
||||
|
||||
/// The user-defined section this channel currently belongs to, or null.
|
||||
final String? sectionId;
|
||||
|
||||
const _ChannelTile({
|
||||
required this.channel,
|
||||
required this.isUnread,
|
||||
required this.currentPubkey,
|
||||
required this.onTap,
|
||||
this.isMuted = false,
|
||||
this.onMarkRead,
|
||||
this.sectionId,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final contentColor = isMuted
|
||||
? navigationSecondaryForeground(context)
|
||||
: navigationPrimaryForeground(
|
||||
context,
|
||||
).withValues(alpha: isUnread ? 1 : 0.8);
|
||||
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(Radii.md),
|
||||
onTap: onTap,
|
||||
onLongPress: () => _showChannelActions(context, ref),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: _kChannelSectionInset,
|
||||
right: _kChannelSectionInset,
|
||||
top: _kChannelRowVerticalPadding,
|
||||
bottom: _kChannelRowVerticalPadding,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: _kChannelLeadingWidth,
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: channel.isDm
|
||||
? _DmAvatar(channel: channel, currentPubkey: currentPubkey)
|
||||
: Icon(
|
||||
channelIcon(channel),
|
||||
key: ValueKey('channel-icon-${channel.id}'),
|
||||
size: _kChannelIconSize,
|
||||
color: contentColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: _kChannelLabelGap),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
resolveDmChannelDisplayLabel(
|
||||
channel,
|
||||
currentPubkey: currentPubkey,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: contentListTitleTextStyle.copyWith(
|
||||
color: contentColor,
|
||||
fontWeight: isUnread ? FontWeight.w700 : FontWeight.w400,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (channel.isEphemeral) ...[
|
||||
const SizedBox(width: Grid.xxs),
|
||||
_EphemeralBadge(channel: channel),
|
||||
],
|
||||
if (isMuted) ...[
|
||||
const SizedBox(width: Grid.xxs),
|
||||
Icon(
|
||||
LucideIcons.bellOff,
|
||||
size: 12,
|
||||
color: context.colors.onSurface.withValues(alpha: 0.4),
|
||||
),
|
||||
],
|
||||
if (!channel.isMember && !channel.isDm)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: Grid.xxs),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Grid.half + 2,
|
||||
vertical: 3,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: context.colors.primary.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(Radii.sm),
|
||||
),
|
||||
child: Text(
|
||||
context.l10n.openChannel,
|
||||
style: context.textTheme.labelSmall?.copyWith(
|
||||
color: context.colors.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showChannelActions(BuildContext context, WidgetRef ref) {
|
||||
showChannelActionsSheet(
|
||||
context: context,
|
||||
channel: channel,
|
||||
isUnread: isUnread,
|
||||
onMarkRead: onMarkRead,
|
||||
sectionId: sectionId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DmAvatar extends ConsumerWidget {
|
||||
final Channel channel;
|
||||
final String? currentPubkey;
|
||||
|
||||
const _DmAvatar({required this.channel, required this.currentPubkey});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final profiles = ref.watch(userCacheProvider);
|
||||
final presenceMap = ref.watch(presenceCacheProvider);
|
||||
final normalizedCurrent = currentPubkey?.toLowerCase();
|
||||
final otherPubkeys = [
|
||||
for (final pk in channel.participantPubkeys)
|
||||
if (pk.toLowerCase() != normalizedCurrent) pk.toLowerCase(),
|
||||
];
|
||||
final visiblePubkeys = otherPubkeys.isNotEmpty
|
||||
? otherPubkeys
|
||||
: channel.participantPubkeys.map((pk) => pk.toLowerCase()).toList();
|
||||
|
||||
if (visiblePubkeys.length > 1) {
|
||||
return Container(
|
||||
width: _kDmAvatarSize,
|
||||
height: _kDmAvatarSize,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: context.colors.surfaceContainerHighest,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: context.colors.outlineVariant),
|
||||
),
|
||||
child: Text(
|
||||
'${visiblePubkeys.length}',
|
||||
style: context.textTheme.labelSmall?.copyWith(
|
||||
fontSize: 9,
|
||||
color: context.colors.onSurface,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final otherPubkey = visiblePubkeys.isNotEmpty ? visiblePubkeys.first : null;
|
||||
final profile = otherPubkey != null ? profiles[otherPubkey] : null;
|
||||
|
||||
// Trigger fetches if not cached yet.
|
||||
if (otherPubkey != null) {
|
||||
if (profile == null) {
|
||||
ref.read(userCacheProvider.notifier).preload([otherPubkey]);
|
||||
}
|
||||
ref.read(presenceCacheProvider.notifier).track([otherPubkey]);
|
||||
}
|
||||
|
||||
final avatarUrl = profile?.avatarUrl;
|
||||
final initial =
|
||||
profile?.initial ??
|
||||
(channel.participants.isNotEmpty
|
||||
? channel.participants.first[0].toUpperCase()
|
||||
: '?');
|
||||
final presence = otherPubkey != null
|
||||
? (presenceMap[otherPubkey] ?? 'offline')
|
||||
: 'offline';
|
||||
|
||||
return SizedBox(
|
||||
width: _kDmAvatarSize,
|
||||
height: _kDmAvatarSize,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
AvatarImage(
|
||||
imageUrl: avatarUrl,
|
||||
radius: _kDmAvatarSize / 2,
|
||||
backgroundColor: context.colors.primaryContainer,
|
||||
fallback: Text(
|
||||
initial,
|
||||
style: context.textTheme.labelSmall?.copyWith(
|
||||
fontSize: 9,
|
||||
color: context.colors.onPrimaryContainer,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
right: -1,
|
||||
bottom: -1,
|
||||
child: Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: _presenceColor(context, presence),
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: context.theme.scaffoldBackgroundColor,
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Color _presenceColor(BuildContext context, String presence) {
|
||||
return switch (presence) {
|
||||
'online' => context.appColors.success,
|
||||
'away' => context.appColors.warning,
|
||||
_ => context.colors.outline,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,552 @@
|
||||
part of '../channels_page.dart';
|
||||
|
||||
class _CommunitySwitcherSheet extends HookConsumerWidget {
|
||||
const _CommunitySwitcherSheet();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final communitiesAsync = ref.watch(communityListProvider);
|
||||
final activeAsync = ref.watch(activeCommunityProvider);
|
||||
final isEditing = useState(false);
|
||||
|
||||
return SafeArea(
|
||||
child: Column(
|
||||
key: const Key('community-switcher-sheet'),
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
Grid.gutter,
|
||||
Grid.xxs,
|
||||
Grid.gutter,
|
||||
Grid.xxs,
|
||||
),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(minHeight: 32),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
context.l10n.switchCommunity,
|
||||
key: const Key('community-switcher-title'),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: context.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: Grid.half),
|
||||
child: ConstrainedBox(
|
||||
key: const Key('community-switcher-edit'),
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: 48,
|
||||
minHeight: 32,
|
||||
),
|
||||
child: TextButton(
|
||||
onPressed: () => isEditing.value = !isEditing.value,
|
||||
style: TextButton.styleFrom(
|
||||
minimumSize: Size.zero,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Grid.half,
|
||||
),
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
child: Text(isEditing.value ? context.l10n.done : context.l10n.edit),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Flexible(
|
||||
child: communitiesAsync.when(
|
||||
loading: () => SizedBox(
|
||||
height: 120,
|
||||
child: Center(
|
||||
child: BuzzLoadingIndicator(
|
||||
size: 40,
|
||||
semanticLabel: context.l10n.loadingCommunities,
|
||||
),
|
||||
),
|
||||
),
|
||||
error: (e, _) => Padding(
|
||||
padding: const EdgeInsets.all(Grid.xs),
|
||||
child: Text(context.l10n.communityLoadFailed(e.toString())),
|
||||
),
|
||||
data: (communities) {
|
||||
final activeId = activeAsync.value?.id;
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.only(bottom: Grid.xs),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Grid.gutter,
|
||||
),
|
||||
child: Material(
|
||||
key: const Key('community-switcher-options'),
|
||||
color: context.colors.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(Radii.card),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
children: [
|
||||
for (
|
||||
var index = 0;
|
||||
index < communities.length;
|
||||
index++
|
||||
) ...[
|
||||
if (index > 0) const _CommunitySwitcherDivider(),
|
||||
_CommunitySwitcherTile(
|
||||
community: communities[index],
|
||||
isActive: communities[index].id == activeId,
|
||||
isEditing: isEditing.value,
|
||||
onTap: isEditing.value
|
||||
? null
|
||||
: () async {
|
||||
final community = communities[index];
|
||||
if (community.id != activeId) {
|
||||
await ref
|
||||
.read(
|
||||
communityListProvider.notifier,
|
||||
)
|
||||
.switchCommunity(community.id);
|
||||
}
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
},
|
||||
onRemove: () => _confirmRemoveCommunity(
|
||||
context,
|
||||
ref,
|
||||
communities[index],
|
||||
closeSheetAfterRemoval:
|
||||
communities[index].id == activeId,
|
||||
),
|
||||
),
|
||||
],
|
||||
if (communities.isNotEmpty)
|
||||
const _CommunitySwitcherDivider(),
|
||||
_AddCommunityTile(
|
||||
onTap: () {
|
||||
final nav = Navigator.of(
|
||||
context,
|
||||
rootNavigator: true,
|
||||
);
|
||||
ref.read(pairingProvider.notifier).reset();
|
||||
Navigator.of(context).pop();
|
||||
nav.push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) =>
|
||||
const PairingPage(addingCommunity: true),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const double _communitySwitcherAvatarSize = 36;
|
||||
|
||||
class _CommunitySwitcherDivider extends StatelessWidget {
|
||||
const _CommunitySwitcherDivider();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Divider(
|
||||
height: 1,
|
||||
thickness: 1,
|
||||
indent: Grid.xs + _communitySwitcherAvatarSize + Grid.xs,
|
||||
endIndent: 0,
|
||||
color: context.colors.onSurface.withValues(alpha: 0.12),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CommunitySwitcherTile extends StatelessWidget {
|
||||
final Community community;
|
||||
final bool isActive;
|
||||
final bool isEditing;
|
||||
final VoidCallback? onTap;
|
||||
final VoidCallback onRemove;
|
||||
|
||||
const _CommunitySwitcherTile({
|
||||
required this.community,
|
||||
required this.isActive,
|
||||
required this.isEditing,
|
||||
required this.onTap,
|
||||
required this.onRemove,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final host = Uri.tryParse(community.relayUrl)?.host ?? community.relayUrl;
|
||||
|
||||
return InkWell(
|
||||
key: Key('community-switcher-row-${community.id}'),
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
Grid.xs,
|
||||
Grid.twelve,
|
||||
Grid.xxs,
|
||||
Grid.twelve,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
_CommunityAvatar(
|
||||
key: Key('community-switcher-avatar-${community.id}'),
|
||||
name: community.name,
|
||||
relayUrl: community.relayUrl,
|
||||
size: _communitySwitcherAvatarSize,
|
||||
),
|
||||
const SizedBox(width: Grid.xs),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
community.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: context.textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: isActive
|
||||
? FontWeight.w600
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: Grid.quarter),
|
||||
Text(
|
||||
host,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: context.textTheme.bodySmall?.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: Grid.xxs),
|
||||
_CommunityActionSwap(
|
||||
key: Key('community-switcher-action-${community.id}'),
|
||||
communityId: community.id,
|
||||
communityName: community.name,
|
||||
isActive: isActive,
|
||||
isEditing: isEditing,
|
||||
onRemove: onRemove,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const _communityActionSwapDuration = Duration(milliseconds: 250);
|
||||
const double _communityActionSwapBlur = 2;
|
||||
const double _communityActionSwapStartScale = 0.25;
|
||||
|
||||
class _CommunityActionSwap extends StatelessWidget {
|
||||
const _CommunityActionSwap({
|
||||
super.key,
|
||||
required this.communityId,
|
||||
required this.communityName,
|
||||
required this.isActive,
|
||||
required this.isEditing,
|
||||
required this.onRemove,
|
||||
});
|
||||
|
||||
final String communityId;
|
||||
final String communityName;
|
||||
final bool isActive;
|
||||
final bool isEditing;
|
||||
final VoidCallback onRemove;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final reduceMotion =
|
||||
MediaQuery.maybeOf(context)?.disableAnimations ?? false;
|
||||
|
||||
return SizedBox.square(
|
||||
dimension: 40,
|
||||
child: AnimatedSwitcher(
|
||||
duration: reduceMotion ? Duration.zero : _communityActionSwapDuration,
|
||||
reverseDuration: reduceMotion
|
||||
? Duration.zero
|
||||
: _communityActionSwapDuration,
|
||||
transitionBuilder: (child, animation) {
|
||||
final eased = CurvedAnimation(
|
||||
parent: animation,
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
return AnimatedBuilder(
|
||||
animation: eased,
|
||||
child: child,
|
||||
builder: (context, child) {
|
||||
final progress = eased.value;
|
||||
final blur = _communityActionSwapBlur * (1 - progress);
|
||||
final scale =
|
||||
_communityActionSwapStartScale +
|
||||
((1 - _communityActionSwapStartScale) * progress);
|
||||
return Opacity(
|
||||
opacity: progress,
|
||||
child: ImageFiltered(
|
||||
enabled: blur > 0.01,
|
||||
imageFilter: ImageFilter.blur(sigmaX: blur, sigmaY: blur),
|
||||
child: Transform.scale(scale: scale, child: child),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
child: isEditing
|
||||
? IconButton(
|
||||
key: ValueKey('community-switcher-remove-$communityId'),
|
||||
tooltip: context.l10n.removeNamedCommunity(communityName),
|
||||
visualDensity: VisualDensity.compact,
|
||||
onPressed: onRemove,
|
||||
icon: Icon(
|
||||
LucideIcons.trash2,
|
||||
size: 18,
|
||||
color: context.colors.error,
|
||||
),
|
||||
)
|
||||
: Center(
|
||||
key: ValueKey('community-switcher-selection-$communityId'),
|
||||
child: _CommunitySelectionIndicator(
|
||||
key: ValueKey('community-switcher-circle-$communityId'),
|
||||
communityName: communityName,
|
||||
isActive: isActive,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CommunitySelectionIndicator extends StatelessWidget {
|
||||
const _CommunitySelectionIndicator({
|
||||
super.key,
|
||||
required this.communityName,
|
||||
required this.isActive,
|
||||
});
|
||||
|
||||
final String communityName;
|
||||
final bool isActive;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Semantics(
|
||||
label: isActive
|
||||
? context.l10n.currentCommunity(communityName)
|
||||
: context.l10n.switchToCommunity(communityName),
|
||||
child: Container(
|
||||
width: 24,
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
color: isActive ? context.appColors.success : Colors.transparent,
|
||||
border: isActive
|
||||
? null
|
||||
: Border.all(color: context.colors.outlineVariant, width: 2),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: isActive
|
||||
? const Icon(LucideIcons.check, color: Colors.white, size: 16)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AddCommunityTile extends StatelessWidget {
|
||||
const _AddCommunityTile({required this.onTap});
|
||||
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
key: const Key('community-switcher-add'),
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Grid.xs,
|
||||
vertical: Grid.twelve,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: _communitySwitcherAvatarSize,
|
||||
height: _communitySwitcherAvatarSize,
|
||||
decoration: BoxDecoration(
|
||||
color: context.colors.primaryContainer,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
LucideIcons.plus,
|
||||
size: 18,
|
||||
color: context.colors.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: Grid.xs),
|
||||
Text(
|
||||
context.l10n.addCommunity,
|
||||
style: context.textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _confirmRemoveCommunity(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
Community community, {
|
||||
required bool closeSheetAfterRemoval,
|
||||
}) async {
|
||||
final confirmed = await showBuzzDialog<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog.adaptive(
|
||||
title: Text(context.l10n.removeCommunityQuestion),
|
||||
content: Text(
|
||||
context.l10n.removeCommunityConfirm(community.name),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(false),
|
||||
child: Text(context.l10n.cancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(true),
|
||||
style: FilledButton.styleFrom(backgroundColor: context.colors.error),
|
||||
child: Text(context.l10n.remove),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed != true || !context.mounted) return;
|
||||
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
try {
|
||||
await ref
|
||||
.read(communityListProvider.notifier)
|
||||
.removeCommunity(community.id);
|
||||
if (closeSheetAfterRemoval && context.mounted) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
} catch (e) {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.failedRemoveCommunity(e.toString()))),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CommunityIndicator extends ConsumerWidget {
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _CommunityIndicator({required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final activeAsync = ref.watch(activeCommunityProvider);
|
||||
|
||||
final activeCommunity = activeAsync.value;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: _CommunityAvatar(
|
||||
name: activeCommunity?.name,
|
||||
relayUrl: activeCommunity?.relayUrl,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CommunityHeaderTitle extends ConsumerWidget {
|
||||
final TextStyle? style;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _CommunityHeaderTitle({required this.onTap, this.style});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final name = ref.watch(activeCommunityProvider).value?.name;
|
||||
final title = name?.trim();
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onTap,
|
||||
child: SizedBox.expand(
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: Grid.xxs),
|
||||
child: Text(
|
||||
title == null || title.isEmpty ? context.l10n.community : title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: style,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CommunityAvatar extends ConsumerWidget {
|
||||
final String? name;
|
||||
final String? relayUrl;
|
||||
final double size;
|
||||
|
||||
const _CommunityAvatar({
|
||||
super.key,
|
||||
required this.name,
|
||||
this.relayUrl,
|
||||
this.size = _kTopSectionAvatarSize,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final trimmedName = name?.trim();
|
||||
final initial = trimmedName != null && trimmedName.isNotEmpty
|
||||
? trimmedName.substring(0, 1).toUpperCase()
|
||||
: '?';
|
||||
final relay = relayUrl;
|
||||
final iconUrl = relay == null
|
||||
? null
|
||||
: ref.watch(communityIconProvider(relay)).value;
|
||||
|
||||
return AvatarImage(
|
||||
imageUrl: iconUrl,
|
||||
radius: size / 2,
|
||||
backgroundColor: context.colors.primaryContainer,
|
||||
fallback: Text(
|
||||
initial,
|
||||
style: context.textTheme.labelMedium?.copyWith(
|
||||
color: context.colors.onPrimaryContainer,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
part of '../channels_page.dart';
|
||||
|
||||
const _kMorphOpenDuration = Duration(milliseconds: 280);
|
||||
const _kMorphCloseDuration = Duration(milliseconds: 240);
|
||||
const _kMorphFadeDuration = Duration(milliseconds: 200);
|
||||
const _kMorphCloseCurve = Cubic(0.22, 1, 0.36, 1);
|
||||
const double _kMorphOpenBounce = 0.14;
|
||||
const double _kMorphCloseBounce = 0.06;
|
||||
const double _kMorphClosedSize = 56;
|
||||
const double _kMorphOpenHeight = 160;
|
||||
const double _kMorphOpenRadius = 20;
|
||||
const double _kMorphSlide = 40;
|
||||
const double _kMorphScale = 0.97;
|
||||
const double _kMorphBlur = 2;
|
||||
const double _kQuickActionCardOverlayOpacity = 0.1;
|
||||
const double _kQuickActionCardRadius = _kMorphOpenRadius - Grid.xxs;
|
||||
|
||||
class _MorphingQuickActionsButton extends HookWidget {
|
||||
final bool open;
|
||||
final double openEdgeOffset;
|
||||
final VoidCallback onToggle;
|
||||
final ValueChanged<_QuickAction> onSelected;
|
||||
|
||||
const _MorphingQuickActionsButton({
|
||||
required this.open,
|
||||
required this.openEdgeOffset,
|
||||
required this.onToggle,
|
||||
required this.onSelected,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final reducedMotion = MediaQuery.of(context).disableAnimations;
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
final openWidth = (mediaQuery.size.width - (Grid.gutter * 2))
|
||||
.clamp(_kMorphClosedSize, double.infinity)
|
||||
.toDouble();
|
||||
final surfaceController = useAnimationController(
|
||||
initialValue: open ? 1 : 0,
|
||||
upperBound: 1.02,
|
||||
);
|
||||
final fadeController = useAnimationController(
|
||||
duration: reducedMotion ? Duration.zero : _kMorphFadeDuration,
|
||||
reverseDuration: reducedMotion ? Duration.zero : _kMorphFadeDuration,
|
||||
initialValue: open ? 1 : 0,
|
||||
);
|
||||
final fadeAnimation = useMemoized(
|
||||
() => CurvedAnimation(
|
||||
parent: fadeController,
|
||||
curve: _kMorphCloseCurve,
|
||||
reverseCurve: _kMorphCloseCurve,
|
||||
),
|
||||
[fadeController],
|
||||
);
|
||||
final animation = useMemoized(
|
||||
() => Listenable.merge([surfaceController, fadeAnimation]),
|
||||
[surfaceController, fadeAnimation],
|
||||
);
|
||||
|
||||
useEffect(() => fadeAnimation.dispose, [fadeAnimation]);
|
||||
|
||||
useEffect(() {
|
||||
fadeController.duration = reducedMotion
|
||||
? Duration.zero
|
||||
: _kMorphFadeDuration;
|
||||
fadeController.reverseDuration = reducedMotion
|
||||
? Duration.zero
|
||||
: _kMorphFadeDuration;
|
||||
|
||||
if (reducedMotion) {
|
||||
surfaceController.value = open ? 1 : 0;
|
||||
fadeController.value = open ? 1 : 0;
|
||||
} else {
|
||||
final target = open ? 1.0 : 0.0;
|
||||
if ((surfaceController.value - target).abs() > 0.001) {
|
||||
unawaited(
|
||||
surfaceController.animateWith(
|
||||
SpringSimulation(
|
||||
SpringDescription.withDurationAndBounce(
|
||||
duration: open ? _kMorphOpenDuration : _kMorphCloseDuration,
|
||||
bounce: open ? _kMorphOpenBounce : _kMorphCloseBounce,
|
||||
),
|
||||
surfaceController.value,
|
||||
target,
|
||||
surfaceController.velocity,
|
||||
snapToEnd: true,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (open) {
|
||||
unawaited(fadeController.forward());
|
||||
} else {
|
||||
unawaited(fadeController.reverse());
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, [fadeController, open, reducedMotion, surfaceController]);
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: animation,
|
||||
builder: (context, _) {
|
||||
final surfaceValue = surfaceController.value;
|
||||
final surfaceProgress = surfaceValue.clamp(0.0, 1.0);
|
||||
final fadeValue = fadeAnimation.value.clamp(0.0, 1.0);
|
||||
final width = lerpDouble(_kMorphClosedSize, openWidth, surfaceValue)!;
|
||||
final height = lerpDouble(
|
||||
_kMorphClosedSize,
|
||||
_kMorphOpenHeight,
|
||||
surfaceValue,
|
||||
)!;
|
||||
final radius = lerpDouble(
|
||||
_kMorphClosedSize / 2,
|
||||
_kMorphOpenRadius,
|
||||
surfaceValue,
|
||||
)!;
|
||||
final borderRadius = BorderRadius.circular(radius);
|
||||
|
||||
return Transform.translate(
|
||||
offset: Offset(openEdgeOffset * surfaceProgress, 0),
|
||||
child: SizedBox(
|
||||
key: const Key('quick-actions-surface'),
|
||||
width: width,
|
||||
height: height,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: context.colors.primary,
|
||||
borderRadius: borderRadius,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: context.colors.shadow.withValues(alpha: 0.24),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: borderRadius,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Material(
|
||||
type: MaterialType.transparency,
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: OverflowBox(
|
||||
alignment: Alignment.bottomRight,
|
||||
minWidth: openWidth,
|
||||
maxWidth: openWidth,
|
||||
minHeight: _kMorphOpenHeight,
|
||||
maxHeight: _kMorphOpenHeight,
|
||||
child: IgnorePointer(
|
||||
ignoring: !open || fadeValue < 0.9,
|
||||
child: ExcludeSemantics(
|
||||
excluding: !open,
|
||||
child: Opacity(
|
||||
opacity: fadeValue,
|
||||
child: ImageFiltered(
|
||||
imageFilter: ImageFilter.blur(
|
||||
sigmaX: _kMorphBlur * (1 - fadeValue),
|
||||
sigmaY: _kMorphBlur * (1 - fadeValue),
|
||||
),
|
||||
child: Transform.translate(
|
||||
offset: Offset(
|
||||
_kMorphSlide * (1 - surfaceProgress),
|
||||
0,
|
||||
),
|
||||
child: Transform.scale(
|
||||
alignment: Alignment.bottomRight,
|
||||
scale:
|
||||
_kMorphScale +
|
||||
((1 - _kMorphScale) *
|
||||
surfaceProgress),
|
||||
child: _QuickActionsMenu(
|
||||
onSelected: onSelected,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
width: _kMorphClosedSize,
|
||||
height: _kMorphClosedSize,
|
||||
child: IgnorePointer(
|
||||
ignoring: open,
|
||||
child: ExcludeSemantics(
|
||||
excluding: open,
|
||||
child: Opacity(
|
||||
opacity: 1 - fadeValue,
|
||||
child: ImageFiltered(
|
||||
imageFilter: ImageFilter.blur(
|
||||
sigmaX: _kMorphBlur * fadeValue,
|
||||
sigmaY: _kMorphBlur * fadeValue,
|
||||
),
|
||||
child: Transform.translate(
|
||||
offset: Offset(
|
||||
-_kMorphSlide * surfaceProgress,
|
||||
0,
|
||||
),
|
||||
child: Transform.rotate(
|
||||
angle: (pi / 4) * surfaceProgress,
|
||||
child: Transform.scale(
|
||||
scale:
|
||||
1 -
|
||||
((1 - _kMorphScale) *
|
||||
surfaceProgress),
|
||||
child: Tooltip(
|
||||
message:
|
||||
context.l10n.createOrStartConversation,
|
||||
child: Semantics(
|
||||
button: true,
|
||||
label:
|
||||
context.l10n.createOrStartConversation,
|
||||
expanded: open,
|
||||
child: InkWell(
|
||||
customBorder: const CircleBorder(),
|
||||
onTap: onToggle,
|
||||
child: Center(
|
||||
child: Icon(
|
||||
LucideIcons.plus,
|
||||
color: context.colors.onPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _QuickActionsMenu extends StatelessWidget {
|
||||
final ValueChanged<_QuickAction> onSelected;
|
||||
|
||||
const _QuickActionsMenu({required this.onSelected});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
key: const Key('quick-actions-menu'),
|
||||
padding: const EdgeInsets.all(Grid.xxs),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_QuickActionItem(
|
||||
icon: LucideIcons.hash,
|
||||
title: context.l10n.createChannel,
|
||||
key: const Key('quick-action-create-channel-card'),
|
||||
onTap: () => onSelected(_QuickAction.createChannel),
|
||||
),
|
||||
const SizedBox(height: Grid.xxs),
|
||||
_QuickActionItem(
|
||||
icon: LucideIcons.messagesSquare,
|
||||
title: context.l10n.newDirectMessage,
|
||||
key: const Key('quick-action-new-dm-card'),
|
||||
onTap: () => onSelected(_QuickAction.newDm),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _QuickActionItem extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _QuickActionItem({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final foreground = context.colors.onPrimary;
|
||||
final background = Color.alphaBlend(
|
||||
foreground.withValues(alpha: _kQuickActionCardOverlayOpacity),
|
||||
context.colors.primary,
|
||||
);
|
||||
|
||||
return Expanded(
|
||||
child: Material(
|
||||
color: background,
|
||||
borderRadius: BorderRadius.circular(_kQuickActionCardRadius),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: Grid.xs),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, size: 24, color: foreground),
|
||||
const SizedBox(width: Grid.xs),
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: context.textTheme.bodyLarge?.copyWith(
|
||||
color: foreground,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
part of '../channels_page.dart';
|
||||
|
||||
const _kQuickActionsTabMotionDuration = Duration(milliseconds: 220);
|
||||
const _kQuickActionsTabMotionCurve = Cubic(0.77, 0, 0.175, 1);
|
||||
const _kQuickActionsHiddenOverlap = Grid.half;
|
||||
const _kQuickActionsHiddenScale = 0.8;
|
||||
|
||||
/// Places the channel quick-actions button beside mobile navigation.
|
||||
///
|
||||
/// The button remains available only on Home and moves behind the navigation
|
||||
/// bar when another destination is selected.
|
||||
class ChannelQuickActionsLauncher extends HookConsumerWidget {
|
||||
/// Whether the launcher should be visible beside the navigation bar.
|
||||
final bool visible;
|
||||
|
||||
/// Height of the navigation bar used to vertically center the closed button.
|
||||
final double navigationBarHeight;
|
||||
|
||||
/// Space between the navigation bar and the bottom safe area.
|
||||
final double navigationBarBottomGap;
|
||||
|
||||
/// Full width of the navigation bar, including its inner edge padding.
|
||||
final double navigationBarWidth;
|
||||
|
||||
/// Bottom system inset used by the navigation bar's [SafeArea].
|
||||
final double systemBottomInset;
|
||||
|
||||
/// Distance between the launcher and the right edge of the screen.
|
||||
final double rightInset;
|
||||
|
||||
/// Creates a launcher aligned with the supplied navigation geometry.
|
||||
const ChannelQuickActionsLauncher({
|
||||
super.key,
|
||||
required this.visible,
|
||||
required this.navigationBarHeight,
|
||||
required this.navigationBarBottomGap,
|
||||
required this.navigationBarWidth,
|
||||
required this.systemBottomInset,
|
||||
required this.rightInset,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final currentPubkey = ref.watch(currentPubkeyProvider);
|
||||
final quickActionsOpen = useState(false);
|
||||
final reducedMotion = MediaQuery.of(context).disableAnimations;
|
||||
final navigationBottomInset = systemBottomInset > navigationBarBottomGap
|
||||
? systemBottomInset
|
||||
: navigationBarBottomGap;
|
||||
final closedBottomInset =
|
||||
navigationBottomInset + ((navigationBarHeight - _kMorphClosedSize) / 2);
|
||||
final openLift =
|
||||
navigationBarHeight +
|
||||
Grid.xxs -
|
||||
((navigationBarHeight - _kMorphClosedSize) / 2);
|
||||
final effectiveOpen = visible && quickActionsOpen.value;
|
||||
final screenWidth = MediaQuery.sizeOf(context).width;
|
||||
final navigationBarRight = (screenWidth + navigationBarWidth) / 2;
|
||||
final launcherRight = screenWidth - rightInset;
|
||||
final hiddenHorizontalOffset =
|
||||
navigationBarRight - launcherRight - _kQuickActionsHiddenOverlap;
|
||||
|
||||
useEffect(() {
|
||||
if (!visible && quickActionsOpen.value) {
|
||||
quickActionsOpen.value = false;
|
||||
}
|
||||
return null;
|
||||
}, [visible]);
|
||||
|
||||
Future<void> openChannel(Channel channel) async {
|
||||
if (!context.mounted) return;
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => ChannelDetailPage(channel: channel),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> selectQuickAction(_QuickAction action) async {
|
||||
quickActionsOpen.value = false;
|
||||
if (!reducedMotion) {
|
||||
await Future<void>.delayed(_kMorphCloseDuration);
|
||||
}
|
||||
if (!context.mounted) return;
|
||||
|
||||
switch (action) {
|
||||
case _QuickAction.createChannel:
|
||||
final created = await showBuzzModalBottomSheet<Channel>(
|
||||
context: context,
|
||||
constraints: _quickActionSheetConstraints(context),
|
||||
isScrollControlled: true,
|
||||
showDragHandle: true,
|
||||
builder: (_) => const _CreateChannelSheet(channelType: 'stream'),
|
||||
);
|
||||
if (created != null && context.mounted) {
|
||||
await openChannel(created);
|
||||
}
|
||||
case _QuickAction.newDm:
|
||||
final opened = await showBuzzModalBottomSheet<Channel>(
|
||||
context: context,
|
||||
constraints: _quickActionSheetConstraints(context),
|
||||
isScrollControlled: true,
|
||||
showDragHandle: true,
|
||||
builder: (_) =>
|
||||
_NewDirectMessageSheet(currentPubkey: currentPubkey),
|
||||
);
|
||||
if (opened != null && context.mounted) {
|
||||
await openChannel(opened);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
if (quickActionsOpen.value)
|
||||
Positioned.fill(
|
||||
child: Semantics(
|
||||
button: true,
|
||||
label: context.l10n.closeQuickActions,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => quickActionsOpen.value = false,
|
||||
),
|
||||
),
|
||||
),
|
||||
AnimatedPositioned(
|
||||
duration: reducedMotion
|
||||
? Duration.zero
|
||||
: _kQuickActionsTabMotionDuration,
|
||||
curve: _kQuickActionsTabMotionCurve,
|
||||
right: rightInset,
|
||||
bottom: closedBottomInset + (effectiveOpen ? openLift : 0),
|
||||
child: IgnorePointer(
|
||||
ignoring: !visible,
|
||||
child: ExcludeSemantics(
|
||||
excluding: !visible,
|
||||
child: TweenAnimationBuilder<double>(
|
||||
key: const Key('channel-quick-actions-motion'),
|
||||
tween: Tween(end: visible ? 0 : 1),
|
||||
duration: reducedMotion
|
||||
? Duration.zero
|
||||
: _kQuickActionsTabMotionDuration,
|
||||
curve: _kQuickActionsTabMotionCurve,
|
||||
builder: (context, hiddenProgress, child) => Opacity(
|
||||
key: const Key('channel-quick-actions-opacity'),
|
||||
opacity: 1 - hiddenProgress,
|
||||
child: Transform.translate(
|
||||
key: const Key('channel-quick-actions-transform'),
|
||||
offset: Offset(hiddenHorizontalOffset * hiddenProgress, 0),
|
||||
child: Transform.scale(
|
||||
key: const Key('channel-quick-actions-scale'),
|
||||
scale:
|
||||
1 -
|
||||
((1 - _kQuickActionsHiddenScale) * hiddenProgress),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: _MorphingQuickActionsButton(
|
||||
open: effectiveOpen,
|
||||
openEdgeOffset: rightInset - Grid.gutter,
|
||||
onToggle: () {
|
||||
unawaited(HapticFeedback.lightImpact());
|
||||
quickActionsOpen.value = !quickActionsOpen.value;
|
||||
},
|
||||
onSelected: (action) => unawaited(selectQuickAction(action)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
BoxConstraints _quickActionSheetConstraints(BuildContext context) {
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
return BoxConstraints(
|
||||
maxHeight: mediaQuery.size.height - mediaQuery.viewPadding.top - Grid.sm,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,722 @@
|
||||
part of '../channels_page.dart';
|
||||
|
||||
const _sectionMenuItemPadding = EdgeInsets.fromLTRB(Grid.xs, 0, Grid.twelve, 0);
|
||||
|
||||
class _CustomChannelSection extends StatelessWidget {
|
||||
final ChannelSection section;
|
||||
final List<Channel> channels;
|
||||
final Set<String> unreadChannelIds;
|
||||
final Set<String> mutedChannelIds;
|
||||
final String? currentPubkey;
|
||||
final bool expanded;
|
||||
final bool isFirst;
|
||||
final bool isLast;
|
||||
final bool showTopDivider;
|
||||
final VoidCallback onToggle;
|
||||
final VoidCallback onRename;
|
||||
final VoidCallback onDelete;
|
||||
final VoidCallback onMoveUp;
|
||||
final VoidCallback onMoveDown;
|
||||
final ChannelSortMode sortMode;
|
||||
final ValueChanged<ChannelSortMode> onSortModeChange;
|
||||
final Future<void> Function(Channel channel) onSelectChannel;
|
||||
final void Function(Channel channel) onMarkChannelRead;
|
||||
|
||||
const _CustomChannelSection({
|
||||
required this.section,
|
||||
required this.channels,
|
||||
required this.unreadChannelIds,
|
||||
required this.mutedChannelIds,
|
||||
required this.currentPubkey,
|
||||
required this.expanded,
|
||||
required this.isFirst,
|
||||
required this.isLast,
|
||||
required this.showTopDivider,
|
||||
required this.onToggle,
|
||||
required this.onRename,
|
||||
required this.onDelete,
|
||||
required this.onMoveUp,
|
||||
required this.onMoveDown,
|
||||
required this.sortMode,
|
||||
required this.onSortModeChange,
|
||||
required this.onSelectChannel,
|
||||
required this.onMarkChannelRead,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (showTopDivider) const _SectionDivider(),
|
||||
_CustomSectionHeader(
|
||||
section: section,
|
||||
expanded: expanded,
|
||||
isFirst: isFirst,
|
||||
isLast: isLast,
|
||||
onToggle: onToggle,
|
||||
onRename: onRename,
|
||||
onDelete: onDelete,
|
||||
onMoveUp: onMoveUp,
|
||||
onMoveDown: onMoveDown,
|
||||
sortMode: sortMode,
|
||||
onSortModeChange: onSortModeChange,
|
||||
),
|
||||
_AnimatedSectionBody(
|
||||
expanded: expanded,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
for (final channel in channels)
|
||||
_ChannelTile(
|
||||
channel: channel,
|
||||
isUnread: unreadChannelIds.contains(channel.id),
|
||||
isMuted: mutedChannelIds.contains(channel.id),
|
||||
currentPubkey: currentPubkey,
|
||||
onTap: () => onSelectChannel(channel),
|
||||
onMarkRead: () => onMarkChannelRead(channel),
|
||||
sectionId: section.id,
|
||||
),
|
||||
const SizedBox(height: _kExpandedSectionTrailingPadding),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CustomSectionHeader extends ConsumerWidget {
|
||||
final ChannelSection section;
|
||||
final bool expanded;
|
||||
final bool isFirst;
|
||||
final bool isLast;
|
||||
final VoidCallback onToggle;
|
||||
final VoidCallback onRename;
|
||||
final VoidCallback onDelete;
|
||||
final VoidCallback onMoveUp;
|
||||
final VoidCallback onMoveDown;
|
||||
final ChannelSortMode sortMode;
|
||||
final ValueChanged<ChannelSortMode> onSortModeChange;
|
||||
|
||||
const _CustomSectionHeader({
|
||||
required this.section,
|
||||
required this.expanded,
|
||||
required this.isFirst,
|
||||
required this.isLast,
|
||||
required this.onToggle,
|
||||
required this.onRename,
|
||||
required this.onDelete,
|
||||
required this.onMoveUp,
|
||||
required this.onMoveDown,
|
||||
required this.sortMode,
|
||||
required this.onSortModeChange,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final sectionColor = navigationSectionForeground(context);
|
||||
final icon = section.icon;
|
||||
final customEmoji = icon == null
|
||||
? null
|
||||
: _resolveCustomEmoji(icon, ref.watch(customEmojiListProvider));
|
||||
|
||||
return GestureDetector(
|
||||
onTap: onToggle,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
Grid.gutter,
|
||||
_kSectionHeaderVerticalPadding,
|
||||
Grid.gutter,
|
||||
_kSectionHeaderVerticalPadding,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: _kChannelLeadingWidth,
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: icon == null || icon.isEmpty
|
||||
? Icon(
|
||||
LucideIcons.folder,
|
||||
size: _kChannelIconSize,
|
||||
color: sectionColor,
|
||||
)
|
||||
: customEmoji != null
|
||||
? CustomEmojiImage(
|
||||
shortcode: customEmoji.shortcode,
|
||||
url: customEmoji.url,
|
||||
size: _kChannelIconSize,
|
||||
)
|
||||
: Text(
|
||||
icon,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.visible,
|
||||
style: TextStyle(
|
||||
fontSize: _kChannelIconSize,
|
||||
height: 1,
|
||||
color: sectionColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: _kChannelLabelGap),
|
||||
Expanded(
|
||||
child: Text(
|
||||
section.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: contentListTitleTextStyle.copyWith(
|
||||
color: sectionColor,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
Builder(
|
||||
builder: (buttonContext) => IconButton(
|
||||
key: ValueKey('section-menu-${section.id}'),
|
||||
tooltip: context.l10n.optionsFor(section.name),
|
||||
visualDensity: VisualDensity.compact,
|
||||
icon: Icon(
|
||||
LucideIcons.ellipsisVertical,
|
||||
size: _kChannelIconSize,
|
||||
color: sectionColor,
|
||||
),
|
||||
onPressed: () async {
|
||||
final value = await showAnchoredPopover<String>(
|
||||
context: buttonContext,
|
||||
width: 216,
|
||||
alignment: AnchoredPopoverAlignment.end,
|
||||
surfaceKey: ValueKey('section-popover-${section.id}'),
|
||||
items: [
|
||||
PopupMenuItem(
|
||||
value: 'rename',
|
||||
padding: _sectionMenuItemPadding,
|
||||
child: _SectionMenuItemContent(
|
||||
icon: LucideIcons.pencil,
|
||||
label: context.l10n.renameSection,
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'move_up',
|
||||
enabled: !isFirst,
|
||||
padding: _sectionMenuItemPadding,
|
||||
child: _SectionMenuItemContent(
|
||||
icon: LucideIcons.arrowUp,
|
||||
label: context.l10n.moveUp,
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'move_down',
|
||||
enabled: !isLast,
|
||||
padding: _sectionMenuItemPadding,
|
||||
child: _SectionMenuItemContent(
|
||||
icon: LucideIcons.arrowDown,
|
||||
label: context.l10n.moveDown,
|
||||
),
|
||||
),
|
||||
..._sortMenuItems(sortMode, l10n: context.l10n),
|
||||
PopupMenuItem(
|
||||
value: 'delete',
|
||||
padding: _sectionMenuItemPadding,
|
||||
child: _SectionMenuItemContent(
|
||||
icon: LucideIcons.trash2,
|
||||
label: context.l10n.deleteSection,
|
||||
color: context.colors.error,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
switch (value) {
|
||||
case 'rename':
|
||||
onRename();
|
||||
case 'move_up':
|
||||
onMoveUp();
|
||||
case 'move_down':
|
||||
onMoveDown();
|
||||
case _kSortRecentMenuValue:
|
||||
onSortModeChange(ChannelSortMode.recent);
|
||||
case _kSortAlphaMenuValue:
|
||||
onSortModeChange(ChannelSortMode.alpha);
|
||||
case 'delete':
|
||||
onDelete();
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: Grid.quarter),
|
||||
_SectionChevron(expanded: expanded, color: sectionColor),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionMenuItemContent extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final Color? color;
|
||||
|
||||
const _SectionMenuItemContent({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
this.color,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
Icon(icon, size: 16, color: color),
|
||||
const SizedBox(width: Grid.xxs),
|
||||
Flexible(
|
||||
child: Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: color == null ? null : TextStyle(color: color),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
CustomEmoji? _resolveCustomEmoji(String icon, List<CustomEmoji> palette) {
|
||||
if (!icon.startsWith(':') || !icon.endsWith(':')) return null;
|
||||
final shortcode = normalizeShortcode(icon);
|
||||
if (shortcode == null) return null;
|
||||
for (final emoji in palette) {
|
||||
if (emoji.shortcode == shortcode) return emoji;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
class _SectionNameDialog extends HookWidget {
|
||||
final String title;
|
||||
final String confirmLabel;
|
||||
final String initialValue;
|
||||
|
||||
const _SectionNameDialog({
|
||||
required this.title,
|
||||
required this.confirmLabel,
|
||||
this.initialValue = '',
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final controller = useTextEditingController(text: initialValue);
|
||||
|
||||
void confirm() {
|
||||
final name = controller.text.trim();
|
||||
if (name.isNotEmpty) Navigator.of(context).pop(name);
|
||||
}
|
||||
|
||||
return AlertDialog(
|
||||
title: Text(title),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
autofocus: true,
|
||||
decoration: InputDecoration(labelText: context.l10n.sectionName),
|
||||
onSubmitted: (_) => confirm(),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text(context.l10n.cancel),
|
||||
),
|
||||
TextButton(onPressed: confirm, child: Text(confirmLabel)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const _kSortRecentMenuValue = 'sort_recent';
|
||||
const _kSortAlphaMenuValue = 'sort_alpha';
|
||||
|
||||
PopupMenuItem<String> _sortMenuItem({
|
||||
required String value,
|
||||
required String label,
|
||||
required bool selected,
|
||||
}) => PopupMenuItem(
|
||||
value: value,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: Text(label)),
|
||||
if (selected)
|
||||
const Icon(LucideIcons.check, key: ValueKey('sort-selected-check'))
|
||||
else
|
||||
const SizedBox(width: 24),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
List<PopupMenuEntry<String>> _sortMenuItems(
|
||||
ChannelSortMode current, {
|
||||
required AppLocalizations l10n,
|
||||
bool showDivider = true,
|
||||
}) => [
|
||||
if (showDivider) const PopupMenuDivider(),
|
||||
_sortMenuItem(
|
||||
value: _kSortRecentMenuValue,
|
||||
label: l10n.sortRecent,
|
||||
selected: current == ChannelSortMode.recent,
|
||||
),
|
||||
_sortMenuItem(
|
||||
value: _kSortAlphaMenuValue,
|
||||
label: l10n.sortAlpha,
|
||||
selected: current == ChannelSortMode.alpha,
|
||||
),
|
||||
];
|
||||
|
||||
class _ChannelSection extends StatelessWidget {
|
||||
final String title;
|
||||
final IconData icon;
|
||||
final bool expanded;
|
||||
final VoidCallback onToggle;
|
||||
final List<Channel> channels;
|
||||
final bool showTopDivider;
|
||||
final Set<String> unreadChannelIds;
|
||||
final Set<String> mutedChannelIds;
|
||||
final String? currentPubkey;
|
||||
final String emptyLabel;
|
||||
final ChannelSortMode? sortMode;
|
||||
final ValueChanged<ChannelSortMode>? onSortModeChange;
|
||||
final Future<void> Function(Channel channel) onSelectChannel;
|
||||
|
||||
const _ChannelSection({
|
||||
required this.title,
|
||||
required this.icon,
|
||||
required this.expanded,
|
||||
required this.onToggle,
|
||||
required this.channels,
|
||||
required this.showTopDivider,
|
||||
required this.unreadChannelIds,
|
||||
required this.mutedChannelIds,
|
||||
required this.currentPubkey,
|
||||
required this.emptyLabel,
|
||||
this.sortMode,
|
||||
this.onSortModeChange,
|
||||
required this.onSelectChannel,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (showTopDivider) const _SectionDivider(),
|
||||
_SectionHeader(
|
||||
label: title,
|
||||
icon: icon,
|
||||
expanded: expanded,
|
||||
onToggle: onToggle,
|
||||
sortMode: sortMode,
|
||||
onSortModeChange: onSortModeChange,
|
||||
),
|
||||
_AnimatedSectionBody(
|
||||
expanded: expanded,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (channels.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: _kChannelLabelInset,
|
||||
right: _kChannelSectionInset,
|
||||
top: Grid.half,
|
||||
bottom: Grid.half,
|
||||
),
|
||||
child: Text(
|
||||
emptyLabel,
|
||||
style: contentListBodyTextStyle.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
for (final channel in channels)
|
||||
_ChannelTile(
|
||||
channel: channel,
|
||||
isUnread: unreadChannelIds.contains(channel.id),
|
||||
isMuted: mutedChannelIds.contains(channel.id),
|
||||
currentPubkey: currentPubkey,
|
||||
onTap: () => onSelectChannel(channel),
|
||||
onMarkRead: null,
|
||||
sectionId: null,
|
||||
),
|
||||
const SizedBox(height: _kExpandedSectionTrailingPadding),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EmptyState extends StatelessWidget {
|
||||
const _EmptyState();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: MediaQuery.sizeOf(context).height * 0.55,
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.messagesSquare,
|
||||
size: Grid.xl,
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(height: Grid.xs),
|
||||
Text(
|
||||
context.l10n.noConversations,
|
||||
style: context.textTheme.bodyLarge?.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionDivider extends StatelessWidget {
|
||||
const _SectionDivider();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: Grid.xxs),
|
||||
child: Divider(
|
||||
height: 1,
|
||||
thickness: 1,
|
||||
indent: _kChannelSectionInset,
|
||||
endIndent: _kChannelSectionInset,
|
||||
color: context.colors.primary.withValues(alpha: 0.15),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionHeader extends StatelessWidget {
|
||||
final String label;
|
||||
final IconData icon;
|
||||
final bool expanded;
|
||||
final VoidCallback onToggle;
|
||||
final ChannelSortMode? sortMode;
|
||||
final ValueChanged<ChannelSortMode>? onSortModeChange;
|
||||
|
||||
const _SectionHeader({
|
||||
required this.label,
|
||||
required this.icon,
|
||||
required this.expanded,
|
||||
required this.onToggle,
|
||||
this.sortMode,
|
||||
this.onSortModeChange,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final sectionColor = navigationSectionForeground(context);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: onToggle,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
Grid.gutter,
|
||||
_kSectionHeaderVerticalPadding,
|
||||
Grid.gutter,
|
||||
_kSectionHeaderVerticalPadding,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: _kChannelLeadingWidth,
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Icon(icon, size: _kChannelIconSize, color: sectionColor),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: _kChannelLabelGap),
|
||||
Text(
|
||||
label,
|
||||
style: contentListTitleTextStyle.copyWith(
|
||||
color: sectionColor,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (sortMode case final mode?) ...[
|
||||
Builder(
|
||||
builder: (buttonContext) => IconButton(
|
||||
key: ValueKey('sort-menu-$label'),
|
||||
tooltip: context.l10n.optionsFor(label),
|
||||
visualDensity: VisualDensity.compact,
|
||||
icon: Icon(
|
||||
LucideIcons.ellipsisVertical,
|
||||
size: _kChannelIconSize,
|
||||
color: sectionColor,
|
||||
),
|
||||
onPressed: () async {
|
||||
final value = await showAnchoredPopover<String>(
|
||||
context: buttonContext,
|
||||
width: 216,
|
||||
alignment: AnchoredPopoverAlignment.end,
|
||||
color: context.colors.surface,
|
||||
elevation: 4,
|
||||
shadowColor: context.colors.shadow.withValues(
|
||||
alpha: 0.18,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(Radii.md),
|
||||
side: BorderSide(color: context.colors.outline),
|
||||
),
|
||||
surfaceKey: ValueKey('sort-popover-$label'),
|
||||
items: _sortMenuItems(
|
||||
mode,
|
||||
l10n: context.l10n,
|
||||
showDivider: false,
|
||||
),
|
||||
);
|
||||
if (value == _kSortRecentMenuValue) {
|
||||
onSortModeChange?.call(ChannelSortMode.recent);
|
||||
} else if (value == _kSortAlphaMenuValue) {
|
||||
onSortModeChange?.call(ChannelSortMode.alpha);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: Grid.quarter),
|
||||
],
|
||||
_SectionChevron(expanded: expanded, color: sectionColor),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionChevron extends StatelessWidget {
|
||||
final bool expanded;
|
||||
final Color color;
|
||||
|
||||
const _SectionChevron({required this.expanded, required this.color});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final reducedMotion = MediaQuery.of(context).disableAnimations;
|
||||
|
||||
return AnimatedRotation(
|
||||
turns: expanded ? 0 : -0.25,
|
||||
duration: reducedMotion ? Duration.zero : _kSectionExpandDuration,
|
||||
curve: _kSectionExpandCurve,
|
||||
child: Icon(
|
||||
LucideIcons.chevronDown,
|
||||
size: _kChannelIconSize,
|
||||
color: color,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AnimatedSectionBody extends HookWidget {
|
||||
final bool expanded;
|
||||
final Widget child;
|
||||
|
||||
const _AnimatedSectionBody({required this.expanded, required this.child});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final reducedMotion = MediaQuery.of(context).disableAnimations;
|
||||
final controller = useAnimationController(
|
||||
duration: reducedMotion ? Duration.zero : _kSectionExpandDuration,
|
||||
reverseDuration: reducedMotion
|
||||
? Duration.zero
|
||||
: _kSectionCollapseDuration,
|
||||
initialValue: expanded ? 1 : 0,
|
||||
);
|
||||
final curvedAnimation = useMemoized(
|
||||
() => CurvedAnimation(
|
||||
parent: controller,
|
||||
curve: _kSectionExpandCurve,
|
||||
reverseCurve: _kSectionCollapseCurve,
|
||||
),
|
||||
[controller],
|
||||
);
|
||||
final shouldRender = useState(expanded);
|
||||
|
||||
useEffect(() => curvedAnimation.dispose, [curvedAnimation]);
|
||||
|
||||
useEffect(() {
|
||||
void handleStatus(AnimationStatus status) {
|
||||
if (status == AnimationStatus.dismissed && !expanded) {
|
||||
shouldRender.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
controller.addStatusListener(handleStatus);
|
||||
return () => controller.removeStatusListener(handleStatus);
|
||||
}, [controller, expanded]);
|
||||
|
||||
useEffect(() {
|
||||
controller.duration = reducedMotion
|
||||
? Duration.zero
|
||||
: _kSectionExpandDuration;
|
||||
controller.reverseDuration = reducedMotion
|
||||
? Duration.zero
|
||||
: _kSectionCollapseDuration;
|
||||
|
||||
if (expanded) {
|
||||
shouldRender.value = true;
|
||||
if (reducedMotion) {
|
||||
controller.value = 1;
|
||||
} else {
|
||||
unawaited(controller.forward());
|
||||
}
|
||||
} else if (reducedMotion) {
|
||||
controller.value = 0;
|
||||
shouldRender.value = false;
|
||||
} else {
|
||||
unawaited(controller.reverse());
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [controller, expanded, reducedMotion]);
|
||||
|
||||
return ClipRect(
|
||||
child: AnimatedBuilder(
|
||||
animation: curvedAnimation,
|
||||
child: shouldRender.value ? child : const SizedBox.shrink(),
|
||||
builder: (context, child) {
|
||||
final value = curvedAnimation.value.clamp(0.0, 1.0);
|
||||
final scaleY =
|
||||
_kSectionCollapsedScaleY +
|
||||
((1 - _kSectionCollapsedScaleY) * value);
|
||||
|
||||
return Align(
|
||||
alignment: Alignment.topCenter,
|
||||
heightFactor: value,
|
||||
child: Opacity(
|
||||
opacity: value,
|
||||
child: Transform.scale(
|
||||
alignment: Alignment.topCenter,
|
||||
scaleY: scaleY,
|
||||
child: ExcludeSemantics(
|
||||
excluding: !expanded,
|
||||
child: IgnorePointer(ignoring: !expanded, child: child),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,909 @@
|
||||
part of '../channels_page.dart';
|
||||
|
||||
const int _defaultCreateChannelTtlSeconds = 7 * 24 * 60 * 60;
|
||||
|
||||
class _CreateChannelMenuOption<T> {
|
||||
final Key? key;
|
||||
final String label;
|
||||
final T value;
|
||||
|
||||
const _CreateChannelMenuOption({
|
||||
this.key,
|
||||
required this.label,
|
||||
required this.value,
|
||||
});
|
||||
}
|
||||
|
||||
const _createChannelTtlOptions = [
|
||||
_CreateChannelMenuOption(label: '30 minutes', value: 30 * 60),
|
||||
_CreateChannelMenuOption(label: '1 hour', value: 60 * 60),
|
||||
_CreateChannelMenuOption(label: '6 hours', value: 6 * 60 * 60),
|
||||
_CreateChannelMenuOption(label: '12 hours', value: 12 * 60 * 60),
|
||||
_CreateChannelMenuOption(label: '1 day', value: 24 * 60 * 60),
|
||||
_CreateChannelMenuOption(label: '3 days', value: 3 * 24 * 60 * 60),
|
||||
_CreateChannelMenuOption(
|
||||
label: '7 days',
|
||||
value: _defaultCreateChannelTtlSeconds,
|
||||
),
|
||||
_CreateChannelMenuOption(label: '14 days', value: 14 * 24 * 60 * 60),
|
||||
_CreateChannelMenuOption(label: '30 days', value: 30 * 24 * 60 * 60),
|
||||
];
|
||||
|
||||
String _localizedTtlLabel(BuildContext context, int seconds) {
|
||||
final l10n = context.l10n;
|
||||
if (seconds % (24 * 60 * 60) == 0) {
|
||||
return l10n.durationDays(seconds ~/ (24 * 60 * 60));
|
||||
}
|
||||
if (seconds % (60 * 60) == 0) {
|
||||
return l10n.durationHours(seconds ~/ (60 * 60));
|
||||
}
|
||||
return l10n.durationMinutes(seconds ~/ 60);
|
||||
}
|
||||
|
||||
class _CreateChannelSheet extends HookConsumerWidget {
|
||||
final String channelType;
|
||||
|
||||
const _CreateChannelSheet({required this.channelType});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final nameController = useTextEditingController();
|
||||
final descriptionController = useTextEditingController();
|
||||
final visibility = useState('open');
|
||||
final temporary = useState(false);
|
||||
final ttlSeconds = useState(_defaultCreateChannelTtlSeconds);
|
||||
final isSubmitting = useState(false);
|
||||
final errorMessage = useState<String?>(null);
|
||||
useListenable(nameController);
|
||||
|
||||
final kindLabel = channelType == 'forum'
|
||||
? context.l10n.forum
|
||||
: context.l10n.channel;
|
||||
final ttlOptions = [
|
||||
for (final option in _createChannelTtlOptions)
|
||||
_CreateChannelMenuOption(
|
||||
key: option.key,
|
||||
label: _localizedTtlLabel(context, option.value),
|
||||
value: option.value,
|
||||
),
|
||||
];
|
||||
final canSubmit =
|
||||
nameController.text.trim().isNotEmpty && !isSubmitting.value;
|
||||
|
||||
Future<void> submit() async {
|
||||
final name = nameController.text.trim();
|
||||
if (name.isEmpty || isSubmitting.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmitting.value = true;
|
||||
errorMessage.value = null;
|
||||
try {
|
||||
final created = await ref
|
||||
.read(channelActionsProvider)
|
||||
.createChannel(
|
||||
name: name,
|
||||
channelType: channelType,
|
||||
visibility: visibility.value,
|
||||
description: descriptionController.text.trim(),
|
||||
ttlSeconds: temporary.value ? ttlSeconds.value : null,
|
||||
);
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).pop(created);
|
||||
}
|
||||
} catch (error) {
|
||||
errorMessage.value = error.toString();
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
Grid.gutter,
|
||||
0,
|
||||
Grid.gutter,
|
||||
MediaQuery.viewInsetsOf(context).bottom + Grid.xs,
|
||||
),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.createNewKind(kindLabel),
|
||||
style: context.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: -0.3,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: Grid.sm),
|
||||
_CreateChannelFieldLabel(label: context.l10n.sectionName),
|
||||
const SizedBox(height: Grid.xxs),
|
||||
_CreateChannelFieldShell(
|
||||
child: TextField(
|
||||
key: const Key('create-channel-name'),
|
||||
controller: nameController,
|
||||
enabled: !isSubmitting.value,
|
||||
autofocus: true,
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
textCapitalization: TextCapitalization.none,
|
||||
decoration: InputDecoration(
|
||||
hintText: channelType == 'forum'
|
||||
? context.l10n.forumNameHint
|
||||
: context.l10n.channelNameHint,
|
||||
border: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: Grid.twelve,
|
||||
vertical: Grid.xs,
|
||||
),
|
||||
),
|
||||
textInputAction: TextInputAction.done,
|
||||
onSubmitted: (_) {
|
||||
if (canSubmit) {
|
||||
unawaited(submit());
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: Grid.xs),
|
||||
_CreateChannelFieldLabel(
|
||||
label: context.l10n.description,
|
||||
optional: true,
|
||||
),
|
||||
const SizedBox(height: Grid.xxs),
|
||||
_CreateChannelFieldShell(
|
||||
child: TextField(
|
||||
key: const Key('create-channel-description'),
|
||||
controller: descriptionController,
|
||||
enabled: !isSubmitting.value,
|
||||
decoration: InputDecoration(
|
||||
hintText: context.l10n.channelPurposeHint(kindLabel),
|
||||
border: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: Grid.twelve,
|
||||
vertical: Grid.xs,
|
||||
),
|
||||
),
|
||||
minLines: 2,
|
||||
maxLines: 3,
|
||||
textInputAction: TextInputAction.done,
|
||||
onSubmitted: (_) {
|
||||
if (canSubmit) {
|
||||
unawaited(submit());
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: Grid.sm),
|
||||
_CreateChannelRadioGroup<bool>(
|
||||
enabled: !isSubmitting.value,
|
||||
label: context.l10n.channelType,
|
||||
onSelected: (value) => temporary.value = value,
|
||||
options: [
|
||||
_CreateChannelMenuOption(
|
||||
key: Key('create-channel-type-ongoing'),
|
||||
label: context.l10n.ongoing,
|
||||
value: false,
|
||||
),
|
||||
_CreateChannelMenuOption(
|
||||
key: Key('create-channel-type-temporary'),
|
||||
label: context.l10n.temporary,
|
||||
value: true,
|
||||
),
|
||||
],
|
||||
value: temporary.value,
|
||||
),
|
||||
if (temporary.value) ...[
|
||||
const SizedBox(height: Grid.xs),
|
||||
_CreateChannelSettingMenu<int>(
|
||||
controlKey: const Key('create-channel-ttl'),
|
||||
enabled: !isSubmitting.value,
|
||||
label: context.l10n.expiresAfter,
|
||||
onSelected: (value) => ttlSeconds.value = value,
|
||||
options: ttlOptions,
|
||||
value: ttlSeconds.value,
|
||||
valueLabel: ttlOptions
|
||||
.firstWhere((option) => option.value == ttlSeconds.value)
|
||||
.label,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: Grid.sm),
|
||||
_CreateChannelRadioGroup<String>(
|
||||
enabled: !isSubmitting.value,
|
||||
label: context.l10n.visibility,
|
||||
onSelected: (value) => visibility.value = value,
|
||||
options: [
|
||||
_CreateChannelMenuOption(
|
||||
key: Key('create-channel-visibility-public'),
|
||||
label: context.l10n.publicVisibility,
|
||||
value: 'open',
|
||||
),
|
||||
_CreateChannelMenuOption(
|
||||
key: Key('create-channel-visibility-private'),
|
||||
label: context.l10n.privateVisibility,
|
||||
value: 'private',
|
||||
),
|
||||
],
|
||||
value: visibility.value,
|
||||
),
|
||||
if (errorMessage.value case final error?) ...[
|
||||
const SizedBox(height: Grid.xs),
|
||||
Text(
|
||||
error,
|
||||
style: context.textTheme.bodySmall?.copyWith(
|
||||
color: context.colors.error,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CreateChannelFieldLabel extends StatelessWidget {
|
||||
final String label;
|
||||
final bool optional;
|
||||
|
||||
const _CreateChannelFieldLabel({required this.label, this.optional = false});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
TextSpan(text: label),
|
||||
if (optional)
|
||||
TextSpan(
|
||||
text: ' ${context.l10n.optional}',
|
||||
style: context.textTheme.labelSmall?.copyWith(
|
||||
color: context.colors.onSurfaceVariant.withValues(alpha: 0.6),
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
style: context.textTheme.labelLarge?.copyWith(
|
||||
color: context.colors.onSurface,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CreateChannelFieldShell extends StatelessWidget {
|
||||
final Widget child;
|
||||
|
||||
const _CreateChannelFieldShell({required this.child});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: context.colors.primaryContainer.withValues(alpha: 0.55),
|
||||
border: Border.all(color: context.colors.outlineVariant),
|
||||
borderRadius: BorderRadius.circular(Radii.lg),
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CreateChannelRadioGroup<T> extends StatelessWidget {
|
||||
final bool enabled;
|
||||
final String label;
|
||||
final ValueChanged<T> onSelected;
|
||||
final List<_CreateChannelMenuOption<T>> options;
|
||||
final T value;
|
||||
|
||||
const _CreateChannelRadioGroup({
|
||||
required this.enabled,
|
||||
required this.label,
|
||||
required this.onSelected,
|
||||
required this.options,
|
||||
required this.value,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_CreateChannelFieldLabel(label: label),
|
||||
const SizedBox(height: Grid.xxs),
|
||||
DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: context.colors.surface,
|
||||
border: Border.all(color: context.colors.outlineVariant),
|
||||
borderRadius: BorderRadius.circular(Radii.lg),
|
||||
),
|
||||
child: RadioGroup<T>(
|
||||
groupValue: value,
|
||||
onChanged: (nextValue) {
|
||||
if (enabled && nextValue != null) {
|
||||
onSelected(nextValue);
|
||||
}
|
||||
},
|
||||
child: Column(
|
||||
children: [
|
||||
for (final (index, option) in options.indexed) ...[
|
||||
RadioListTile<T>(
|
||||
key: option.key,
|
||||
value: option.value,
|
||||
enabled: enabled,
|
||||
selected: option.value == value,
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: Grid.xs,
|
||||
vertical: Grid.half,
|
||||
),
|
||||
visualDensity: VisualDensity.standard,
|
||||
activeColor: context.colors.primary,
|
||||
title: Text(
|
||||
option.label,
|
||||
style: context.textTheme.bodyMedium?.copyWith(
|
||||
color: enabled
|
||||
? context.colors.onSurface
|
||||
: context.colors.onSurface.withValues(alpha: 0.38),
|
||||
fontWeight: option.value == value
|
||||
? FontWeight.w600
|
||||
: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (index < options.length - 1)
|
||||
Divider(
|
||||
height: 1,
|
||||
indent: Grid.xs,
|
||||
endIndent: Grid.xs,
|
||||
color: context.colors.outlineVariant,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CreateChannelSettingMenu<T> extends StatelessWidget {
|
||||
final Key controlKey;
|
||||
final bool enabled;
|
||||
final String label;
|
||||
final ValueChanged<T> onSelected;
|
||||
final List<_CreateChannelMenuOption<T>> options;
|
||||
final T value;
|
||||
final IconData? valueIcon;
|
||||
final String valueLabel;
|
||||
|
||||
const _CreateChannelSettingMenu({
|
||||
required this.controlKey,
|
||||
required this.enabled,
|
||||
required this.label,
|
||||
required this.onSelected,
|
||||
required this.options,
|
||||
required this.value,
|
||||
this.valueIcon,
|
||||
required this.valueLabel,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: context.colors.surface,
|
||||
border: Border.all(color: context.colors.outlineVariant),
|
||||
borderRadius: BorderRadius.circular(Radii.lg),
|
||||
),
|
||||
child: PopupMenuButton<T>(
|
||||
enabled: enabled,
|
||||
initialValue: value,
|
||||
onSelected: onSelected,
|
||||
tooltip: '$label: $valueLabel',
|
||||
itemBuilder: (context) => [
|
||||
for (final option in options)
|
||||
CheckedPopupMenuItem<T>(
|
||||
checked: option.value == value,
|
||||
value: option.value,
|
||||
child: Text(option.label),
|
||||
),
|
||||
],
|
||||
child: Padding(
|
||||
key: controlKey,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Grid.twelve,
|
||||
vertical: Grid.xs,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
style: context.textTheme.labelLarge?.copyWith(
|
||||
color: context.colors.onSurface,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (valueIcon case final icon?) ...[
|
||||
Icon(icon, size: 16, color: context.colors.onSurfaceVariant),
|
||||
const SizedBox(width: Grid.half),
|
||||
],
|
||||
Text(
|
||||
valueLabel,
|
||||
style: context.textTheme.bodyMedium?.copyWith(
|
||||
color: context.colors.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: Grid.half),
|
||||
Icon(
|
||||
LucideIcons.chevronDown,
|
||||
size: 16,
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NewDirectMessageSheet extends HookConsumerWidget {
|
||||
final String? currentPubkey;
|
||||
|
||||
const _NewDirectMessageSheet({required this.currentPubkey});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final queryController = useTextEditingController();
|
||||
final queryFocusNode = useFocusNode();
|
||||
final query = useState('');
|
||||
final debouncedQuery = useState('');
|
||||
final selectedUsers = useState<List<DirectoryUser>>([]);
|
||||
final isSubmitting = useState(false);
|
||||
final submitError = useState<String?>(null);
|
||||
final previewDirectoryActive = ref.watch(dmDirectoryPreviewEnabledProvider);
|
||||
|
||||
useEffect(() {
|
||||
final timer = Timer(const Duration(milliseconds: 250), () {
|
||||
debouncedQuery.value = query.value.trim().toLowerCase();
|
||||
});
|
||||
return timer.cancel;
|
||||
}, [query.value]);
|
||||
|
||||
final normalizedQuery = previewDirectoryActive
|
||||
? query.value.trim().toLowerCase()
|
||||
: debouncedQuery.value;
|
||||
final isSearchTransitionPending =
|
||||
!previewDirectoryActive &&
|
||||
query.value.trim().toLowerCase() != normalizedQuery;
|
||||
final directoryAsync = previewDirectoryActive
|
||||
? AsyncValue.data(dmDirectoryPreviewUsers)
|
||||
: normalizedQuery.isEmpty
|
||||
? ref.watch(relayDirectoryUsersProvider)
|
||||
: ref.watch(relayDirectorySearchProvider(normalizedQuery));
|
||||
|
||||
final selectedPubkeys = selectedUsers.value
|
||||
.map((user) => user.pubkey.toLowerCase())
|
||||
.toSet();
|
||||
final availableResults =
|
||||
directoryAsync.asData?.value.where((user) {
|
||||
final normalizedPubkey = user.pubkey.toLowerCase();
|
||||
if (selectedPubkeys.contains(normalizedPubkey) ||
|
||||
normalizedPubkey == currentPubkey?.toLowerCase()) {
|
||||
return false;
|
||||
}
|
||||
if (normalizedQuery.isEmpty) {
|
||||
return true;
|
||||
}
|
||||
return user.label.toLowerCase().contains(normalizedQuery) ||
|
||||
user.secondaryLabel.toLowerCase().contains(normalizedQuery) ||
|
||||
normalizedPubkey.contains(normalizedQuery);
|
||||
}).toList() ??
|
||||
const <DirectoryUser>[];
|
||||
final canSubmit = !isSubmitting.value && selectedUsers.value.isNotEmpty;
|
||||
|
||||
Future<void> submit() async {
|
||||
if (selectedUsers.value.isEmpty || isSubmitting.value) {
|
||||
return;
|
||||
}
|
||||
if (previewDirectoryActive) {
|
||||
submitError.value = context.l10n.previewDmUnavailable;
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmitting.value = true;
|
||||
submitError.value = null;
|
||||
try {
|
||||
final channel = await ref
|
||||
.read(channelActionsProvider)
|
||||
.openDm(
|
||||
pubkeys: selectedUsers.value.map((user) => user.pubkey).toList(),
|
||||
);
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).pop(channel);
|
||||
}
|
||||
} catch (error) {
|
||||
submitError.value = error.toString();
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
Grid.gutter,
|
||||
0,
|
||||
Grid.gutter,
|
||||
MediaQuery.viewInsetsOf(context).bottom + Grid.xs,
|
||||
),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.newMessage,
|
||||
style: context.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: -0.3,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: Grid.xs),
|
||||
GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onTap: isSubmitting.value ? null : queryFocusNode.requestFocus,
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: ConstrainedBox(
|
||||
key: const Key('new-dm-recipient-field'),
|
||||
constraints: const BoxConstraints(minHeight: Grid.xl),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: context.colors.surface,
|
||||
border: Border.all(
|
||||
color: context.colors.outlineVariant,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(Radii.lg),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Grid.twelve,
|
||||
vertical: Grid.xxs,
|
||||
),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final hasSelectedUsers =
|
||||
selectedUsers.value.isNotEmpty;
|
||||
final searchFieldWidth = hasSelectedUsers
|
||||
? 96.0
|
||||
: (constraints.maxWidth - 32).clamp(
|
||||
96.0,
|
||||
constraints.maxWidth,
|
||||
);
|
||||
|
||||
return Wrap(
|
||||
key: const Key('new-dm-recipient-wrap'),
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: Grid.half,
|
||||
),
|
||||
child: Text(
|
||||
context.l10n.toLabel,
|
||||
style: context.textTheme.bodyLarge
|
||||
?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
for (final user in selectedUsers.value)
|
||||
_SelectedDmRecipientChip(
|
||||
user: user,
|
||||
enabled: !isSubmitting.value,
|
||||
onDeleted: () {
|
||||
selectedUsers.value = [
|
||||
for (final candidate
|
||||
in selectedUsers.value)
|
||||
if (candidate.pubkey != user.pubkey)
|
||||
candidate,
|
||||
];
|
||||
queryFocusNode.requestFocus();
|
||||
},
|
||||
),
|
||||
SizedBox(
|
||||
width: searchFieldWidth,
|
||||
child: TextField(
|
||||
key: const Key('new-dm-search'),
|
||||
controller: queryController,
|
||||
focusNode: queryFocusNode,
|
||||
autofocus: true,
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
enabled: !isSubmitting.value,
|
||||
onChanged: (value) => query.value = value,
|
||||
onSubmitted: (_) {
|
||||
if (canSubmit) {
|
||||
unawaited(submit());
|
||||
}
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
hintText: hasSelectedUsers
|
||||
? null
|
||||
: context.l10n.searchPerson,
|
||||
border: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
isDense: true,
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(
|
||||
vertical: Grid.half,
|
||||
),
|
||||
suffixIcon: isSubmitting.value
|
||||
? Padding(
|
||||
padding: EdgeInsets.all(
|
||||
Grid.half,
|
||||
),
|
||||
child: SizedBox.square(
|
||||
dimension: 16,
|
||||
child: BuzzLoadingIndicator(
|
||||
size: 16,
|
||||
semanticLabel:
|
||||
context
|
||||
.l10n
|
||||
.creatingConversation,
|
||||
),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
suffixIconConstraints:
|
||||
const BoxConstraints(
|
||||
minHeight: 32,
|
||||
minWidth: 32,
|
||||
),
|
||||
),
|
||||
textInputAction: TextInputAction.done,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: Grid.xs),
|
||||
Builder(
|
||||
key: const Key('new-dm-results'),
|
||||
builder: (context) {
|
||||
if (selectedUsers.value.length >= 8) {
|
||||
return SizedBox(
|
||||
height: 96,
|
||||
child: Center(
|
||||
child: Text(
|
||||
context.l10n.dmPeopleLimit,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (directoryAsync.isLoading &&
|
||||
directoryAsync.asData == null ||
|
||||
isSearchTransitionPending) {
|
||||
return SizedBox(
|
||||
height: 280,
|
||||
child: Center(
|
||||
child: BuzzLoadingIndicator(
|
||||
size: 44,
|
||||
semanticLabel: context.l10n.loadingPeople,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (directoryAsync.hasError) {
|
||||
return SizedBox(
|
||||
height: 280,
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(context.l10n.peopleLoadFailed),
|
||||
const SizedBox(height: Grid.half),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
if (normalizedQuery.isEmpty) {
|
||||
ref.invalidate(relayDirectoryUsersProvider);
|
||||
} else {
|
||||
ref.invalidate(
|
||||
relayDirectorySearchProvider(
|
||||
normalizedQuery,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: Text(context.l10n.retry),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (availableResults.isEmpty) {
|
||||
return SizedBox(
|
||||
height: 280,
|
||||
child: Center(
|
||||
child: Text(
|
||||
normalizedQuery.isEmpty
|
||||
? context.l10n.noPeopleAvailable
|
||||
: context.l10n.noMatchingUsers,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return ListView.separated(
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
shrinkWrap: true,
|
||||
itemCount: availableResults.length,
|
||||
separatorBuilder: (_, _) =>
|
||||
const Divider(height: 1, indent: 56),
|
||||
itemBuilder: (context, index) {
|
||||
final user = availableResults[index];
|
||||
return ListTile(
|
||||
key: Key('new-dm-person-${user.pubkey}'),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: Grid.half,
|
||||
),
|
||||
leading: AvatarImage(
|
||||
imageUrl: user.avatarUrl,
|
||||
radius: 20,
|
||||
backgroundColor: context.colors.primaryContainer,
|
||||
fallback: Text(
|
||||
user.initial,
|
||||
style: context.textTheme.labelLarge?.copyWith(
|
||||
color: context.colors.onPrimaryContainer,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
user.label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: Text(
|
||||
user.secondaryLabel,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
trailing: Icon(
|
||||
LucideIcons.plus,
|
||||
size: 18,
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
onTap: isSubmitting.value
|
||||
? null
|
||||
: () {
|
||||
selectedUsers.value = [
|
||||
...selectedUsers.value,
|
||||
user,
|
||||
];
|
||||
queryController.clear();
|
||||
query.value = '';
|
||||
debouncedQuery.value = '';
|
||||
submitError.value = null;
|
||||
queryFocusNode.requestFocus();
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
if (submitError.value case final error?) ...[
|
||||
const SizedBox(height: Grid.xxs),
|
||||
Text(
|
||||
error,
|
||||
style: context.textTheme.bodySmall?.copyWith(
|
||||
color: context.colors.error,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SelectedDmRecipientChip extends StatelessWidget {
|
||||
final DirectoryUser user;
|
||||
final bool enabled;
|
||||
final VoidCallback onDeleted;
|
||||
|
||||
const _SelectedDmRecipientChip({
|
||||
required this.user,
|
||||
required this.enabled,
|
||||
required this.onDeleted,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final foreground = context.colors.onSurfaceVariant;
|
||||
|
||||
return ConstrainedBox(
|
||||
key: Key('new-dm-selected-${user.pubkey}'),
|
||||
constraints: const BoxConstraints(maxWidth: 224),
|
||||
child: SizedBox(
|
||||
height: 40,
|
||||
child: Material(
|
||||
color: context.colors.surfaceContainerHighest,
|
||||
shape: const StadiumBorder(),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Semantics(
|
||||
button: true,
|
||||
enabled: enabled,
|
||||
excludeSemantics: true,
|
||||
label: context.l10n.removePerson(user.label),
|
||||
child: InkWell(
|
||||
customBorder: const StadiumBorder(),
|
||||
onTap: enabled ? onDeleted : null,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: Grid.half,
|
||||
right: Grid.twelve,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
AvatarImage(
|
||||
imageUrl: user.avatarUrl,
|
||||
radius: 16,
|
||||
backgroundColor: context.colors.primaryContainer,
|
||||
fallback: Text(
|
||||
user.initial,
|
||||
style: context.textTheme.labelMedium?.copyWith(
|
||||
color: context.colors.onPrimaryContainer,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: Grid.xxs),
|
||||
Flexible(
|
||||
child: Text(
|
||||
user.label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: context.textTheme.bodyLarge?.copyWith(
|
||||
color: foreground,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
part of '../channels_page.dart';
|
||||
|
||||
class _ChannelsSkeleton extends StatelessWidget {
|
||||
final List<Channel>? channels;
|
||||
final double topInset;
|
||||
final SessionStatus status;
|
||||
|
||||
const _ChannelsSkeleton({
|
||||
required this.channels,
|
||||
required this.topInset,
|
||||
required this.status,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final visibleChannels =
|
||||
channels
|
||||
?.where((channel) => channel.isMember && !channel.isArchived)
|
||||
.take(8)
|
||||
.toList() ??
|
||||
const <Channel>[];
|
||||
final widths = visibleChannels
|
||||
.map(
|
||||
(channel) => min(
|
||||
240,
|
||||
max(
|
||||
88,
|
||||
24 +
|
||||
(resolveDmChannelDisplayLabel(
|
||||
channel,
|
||||
currentPubkey: null,
|
||||
).length *
|
||||
8),
|
||||
),
|
||||
).toDouble(),
|
||||
)
|
||||
.toList();
|
||||
const fallbackWidths = <double>[136, 184, 112, 160, 208, 128];
|
||||
while (widths.length < 6) {
|
||||
widths.add(fallbackWidths[widths.length % fallbackWidths.length]);
|
||||
}
|
||||
final splitAt = min(4, widths.length);
|
||||
final firstSection = widths.take(splitAt).toList();
|
||||
final secondSection = widths.skip(splitAt).toList();
|
||||
|
||||
final semanticsLabel = switch (status) {
|
||||
SessionStatus.connecting => context.l10n.connecting,
|
||||
SessionStatus.reconnecting => context.l10n.reconnecting,
|
||||
SessionStatus.connected || SessionStatus.disconnected =>
|
||||
context.l10n.loading,
|
||||
};
|
||||
return Semantics(
|
||||
key: const Key('channels-connection-skeleton'),
|
||||
liveRegion: true,
|
||||
label: semanticsLabel,
|
||||
child: ExcludeSemantics(
|
||||
child: ListView(
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
Grid.gutter,
|
||||
topInset + Grid.twelve,
|
||||
Grid.gutter,
|
||||
80,
|
||||
),
|
||||
children: [
|
||||
_ChannelSkeletonSection(widths: firstSection),
|
||||
const SizedBox(height: Grid.xs),
|
||||
_ChannelSkeletonSection(widths: secondSection),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ChannelSkeletonSection extends StatelessWidget {
|
||||
final List<double> widths;
|
||||
|
||||
const _ChannelSkeletonSection({required this.widths});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: Grid.half),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: _kChannelLeadingWidth,
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: SkeletonBar(width: 18, height: 18),
|
||||
),
|
||||
),
|
||||
SizedBox(width: Grid.xxs),
|
||||
SkeletonBar(
|
||||
key: Key('channels-skeleton-section-label'),
|
||||
width: 88,
|
||||
height: 16,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
for (var index = 0; index < widths.length; index++)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: _kChannelRowVerticalPadding,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: _kChannelLeadingWidth,
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: SkeletonBar(
|
||||
width: _kChannelIconSize,
|
||||
height: _kChannelIconSize,
|
||||
borderRadius: BorderRadius.circular(
|
||||
index.isEven ? Radii.xs : Radii.full,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: _kChannelLabelGap),
|
||||
SkeletonBar(
|
||||
key: Key('channels-skeleton-row-label-$index'),
|
||||
width: widths[index],
|
||||
height: 16,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user