Files
buzz/mobile/lib/features/channels/channels_page/community.dart
T
cls 9dfa06ffee
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
feat: import Chinese-localized Buzz source snapshot
Signed-off-by: cls_宁波本机 <908705107@qq.com>
2026-08-13 18:34:25 +08:00

553 lines
18 KiB
Dart

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,
),
),
);
}
}