Files
buzz/mobile/lib/features/channels/channels_page/sheets.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

910 lines
33 KiB
Dart

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