feat(widgets): add WnProfileSwitcherItem component (#188)

* feat(widgets): add WnProfileSwitcherItem component

- Create reusable profile switcher item for switch profile screen
- Use WnAvatarSize.medium (56px) per Figma specs
- Use WnMiddleEllipsisText for npub display with middle truncation
- Add widgetbook showcase with real npub examples
- Refactor SwitchProfileScreen to use new component

Closes #141

* fix: rename sloth package imports to whitenoise and fix l10n validation

- Update wn_profile_switcher_item imports from sloth to whitenoise
- Update test and widgetbook imports from sloth to whitenoise
- Fix validate-locales-keys.sh to use LC_ALL=C for consistent sorting
- Include auto-generated plugin registrant updates

* refactor: compute formatted pubkey once in WnProfileSwitcherItem build

Extract formatPublicKey(npubFromHex(pubkey) ?? pubkey) into a local
final variable to avoid duplicate computation in the build method.
This commit is contained in:
JeffG
2026-02-06 11:54:47 +01:00
committed by GitHub
parent 113b4fb87c
commit d01e480dfa
8 changed files with 581 additions and 65 deletions
+10 -58
View File
@@ -7,10 +7,9 @@ import 'package:whitenoise/hooks/use_user_metadata.dart';
import 'package:whitenoise/providers/auth_provider.dart';
import 'package:whitenoise/routes.dart';
import 'package:whitenoise/theme.dart';
import 'package:whitenoise/utils/formatting.dart';
import 'package:whitenoise/utils/metadata.dart';
import 'package:whitenoise/widgets/wn_avatar.dart';
import 'package:whitenoise/widgets/wn_button.dart';
import 'package:whitenoise/widgets/wn_profile_switcher_item.dart';
import 'package:whitenoise/widgets/wn_slate.dart';
import 'package:whitenoise/widgets/wn_slate_navigation_header.dart';
@@ -95,9 +94,10 @@ class SwitchProfileScreen extends HookConsumerWidget {
),
),
)
: ListView.builder(
padding: EdgeInsets.symmetric(horizontal: 16.w),
: ListView.separated(
padding: EdgeInsets.zero,
itemCount: accountsList.length,
separatorBuilder: (context, index) => Gap(8.h),
itemBuilder: (context, index) {
final account = accountsList[index];
final isCurrentAccount = account.pubkey == currentPubkey;
@@ -144,64 +144,16 @@ class _AccountTile extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final colors = context.colors;
final typography = context.typographyScaled;
final metadataSnapshot = useUserMetadata(context, pubkey);
final metadata = metadataSnapshot.data;
final displayName = presentName(metadata);
return GestureDetector(
onTap: isSwitching ? null : onTap,
behavior: HitTestBehavior.opaque,
child: Padding(
padding: EdgeInsets.symmetric(vertical: 12.h),
child: Row(
children: [
WnAvatar(
pictureUrl: metadata?.picture,
displayName: displayName,
color: AvatarColor.fromPubkey(pubkey),
),
SizedBox(width: 12.w),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (displayName != null)
Text(
displayName,
style: typography.semiBold16.copyWith(
color: colors.backgroundContentPrimary,
),
),
Text(
formatPublicKey(npubFromHex(pubkey) ?? pubkey),
style: typography.medium12.copyWith(
color: colors.backgroundContentSecondary,
),
),
],
),
),
if (isCurrent)
Icon(
key: const Key('current_account_checkmark'),
Icons.check_circle,
color: colors.backgroundContentPrimary,
size: 24.w,
),
if (isSwitching && !isCurrent)
SizedBox(
width: 24.w,
height: 24.w,
child: CircularProgressIndicator(
strokeWidth: 2,
color: colors.backgroundContentPrimary,
),
),
],
),
),
return WnProfileSwitcherItem(
pubkey: pubkey,
displayName: displayName,
pictureUrl: metadata?.picture,
isSelected: isCurrent,
onTap: isSwitching ? () {} : onTap,
);
}
}
+96
View File
@@ -0,0 +1,96 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:gap/gap.dart' show Gap;
import 'package:whitenoise/theme.dart';
import 'package:whitenoise/utils/formatting.dart';
import 'package:whitenoise/widgets/wn_avatar.dart';
import 'package:whitenoise/widgets/wn_icon.dart';
import 'package:whitenoise/widgets/wn_middle_ellipsis_text.dart';
class WnProfileSwitcherItem extends StatelessWidget {
const WnProfileSwitcherItem({
super.key,
required this.pubkey,
this.displayName,
this.pictureUrl,
this.isSelected = false,
required this.onTap,
});
final String pubkey;
final String? displayName;
final String? pictureUrl;
final bool isSelected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final colors = context.colors;
final typography = context.typographyScaled;
final formattedPubkey = formatPublicKey(npubFromHex(pubkey) ?? pubkey);
final backgroundColor = isSelected ? colors.fillTertiaryActive : colors.fillTertiary;
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Container(
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 10.h),
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: BorderRadius.circular(8.r),
),
child: Row(
children: [
WnAvatar(
pictureUrl: pictureUrl,
displayName: displayName,
size: WnAvatarSize.medium,
color: AvatarColor.fromPubkey(pubkey),
),
Gap(8.w),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (displayName != null)
Text(
displayName!,
style: typography.medium16.copyWith(
color: colors.backgroundContentPrimary,
),
overflow: TextOverflow.ellipsis,
maxLines: 1,
)
else
WnMiddleEllipsisText(
text: formattedPubkey,
style: typography.medium16.copyWith(
color: colors.backgroundContentPrimary,
),
),
Gap(4.h),
WnMiddleEllipsisText(
text: formattedPubkey,
style: typography.medium12.copyWith(
color: colors.backgroundContentSecondary,
),
),
],
),
),
if (isSelected) ...[
Gap(8.w),
WnIcon(
WnIcons.checkmark,
key: const Key('profile_switcher_item_checkmark'),
color: colors.backgroundContentSecondary,
size: 18.sp,
),
],
],
),
),
);
}
}
+3
View File
@@ -1,6 +1,9 @@
#!/bin/bash
set -eo pipefail
# Use C locale for consistent sorting across different environments
export LC_ALL=C
TEMPLATE="${1:-lib/l10n/app_en.arb}"
ERRORS=0
MISSING_COUNT=0
+1 -2
View File
@@ -262,12 +262,11 @@ void main() {
await tester.tap(find.text('Display $testPubkeyB'));
await tester.pump();
expect(find.byType(CircularProgressIndicator), findsOneWidget);
expect(find.text('Profiles'), findsOneWidget);
mockAuthNotifier.switchProfileCompleter!.complete();
await tester.pumpAndSettle();
expect(find.byType(CircularProgressIndicator), findsNothing);
expect(find.text('Failed to switch profile. Please try again.'), findsNothing);
});
+12 -5
View File
@@ -9,6 +9,7 @@ import 'package:whitenoise/src/rust/api/accounts.dart';
import 'package:whitenoise/src/rust/frb_generated.dart';
import 'package:whitenoise/utils/avatar_color.dart' show AvatarColor;
import 'package:whitenoise/widgets/wn_avatar.dart' show WnAvatar;
import 'package:whitenoise/widgets/wn_middle_ellipsis_text.dart';
import '../mocks/mock_secure_storage.dart';
import '../mocks/mock_wn_api.dart';
@@ -110,7 +111,7 @@ void main() {
testWidgets('displays checkmark for current account', (tester) async {
await pumpSwitchProfileScreen(tester, testPubkeyA);
expect(find.byKey(const Key('current_account_checkmark')), findsOneWidget);
expect(find.byKey(const Key('profile_switcher_item_checkmark')), findsOneWidget);
});
testWidgets('displays Connect Another Profile button', (tester) async {
@@ -147,7 +148,7 @@ void main() {
expect(find.text('No accounts available'), findsOneWidget);
});
testWidgets('shows loading indicator while switching profile', (tester) async {
testWidgets('disables taps while switching profile', (tester) async {
final mockAuthNotifier = _MockAuthNotifier(testPubkeyA);
mockAuthNotifier.switchProfileCompleter = Completer<void>();
@@ -160,7 +161,7 @@ void main() {
await tester.tap(find.text('Display $testPubkeyB'));
await tester.pump();
expect(find.byType(CircularProgressIndicator), findsOneWidget);
expect(find.text('Profiles'), findsOneWidget);
mockAuthNotifier.switchProfileCompleter!.complete();
await tester.pumpAndSettle();
@@ -193,14 +194,20 @@ void main() {
testWidgets('displays formatted npub for first account', (tester) async {
await pumpSwitchProfileScreen(tester, testPubkeyA);
expect(find.textContaining(testNpubAFormatted), findsOneWidget);
final middleEllipsisWidgets = tester.widgetList<WnMiddleEllipsisText>(
find.byType(WnMiddleEllipsisText),
);
expect(middleEllipsisWidgets.any((w) => w.text.startsWith('npub 1a1b')), isTrue);
});
testWidgets('displays formatted npub for second account', (tester) async {
await pumpSwitchProfileScreen(tester, testPubkeyA);
await tester.drag(find.byType(ListView), const Offset(0, -400));
await tester.pumpAndSettle();
expect(find.textContaining(testNpubBFormatted), findsOneWidget);
final middleEllipsisWidgets = tester.widgetList<WnMiddleEllipsisText>(
find.byType(WnMiddleEllipsisText),
);
expect(middleEllipsisWidgets.any((w) => w.text.startsWith('npub 1b2c')), isTrue);
});
});
}
@@ -0,0 +1,174 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:whitenoise/widgets/wn_profile_switcher_item.dart';
import '../test_helpers.dart';
void main() {
group('WnProfileSwitcherItem', () {
testWidgets('displays name and public key', (tester) async {
await mountWidget(
WnProfileSwitcherItem(
pubkey: testPubkeyA,
displayName: 'Alice',
onTap: () {},
),
tester,
);
expect(find.text('Alice'), findsOneWidget);
expect(find.byType(Text), findsAtLeast(2));
});
testWidgets('displays avatar', (tester) async {
await mountWidget(
WnProfileSwitcherItem(
pubkey: testPubkeyA,
displayName: 'Alice',
onTap: () {},
),
tester,
);
expect(find.text('A'), findsOneWidget);
});
testWidgets('displays checkmark when selected', (tester) async {
await mountWidget(
WnProfileSwitcherItem(
pubkey: testPubkeyA,
displayName: 'Alice',
isSelected: true,
onTap: () {},
),
tester,
);
expect(find.byKey(const Key('profile_switcher_item_checkmark')), findsOneWidget);
});
testWidgets('does not display checkmark when not selected', (tester) async {
await mountWidget(
WnProfileSwitcherItem(
pubkey: testPubkeyA,
displayName: 'Alice',
onTap: () {},
),
tester,
);
expect(find.byKey(const Key('profile_switcher_item_checkmark')), findsNothing);
});
testWidgets('calls onTap when tapped', (tester) async {
bool tapped = false;
await mountWidget(
WnProfileSwitcherItem(
pubkey: testPubkeyA,
displayName: 'Alice',
onTap: () => tapped = true,
),
tester,
);
await tester.tap(find.text('Alice'));
await tester.pumpAndSettle();
expect(tapped, true);
});
testWidgets('displays formatted pubkey when no displayName', (tester) async {
await mountWidget(
WnProfileSwitcherItem(
pubkey: testPubkeyA,
onTap: () {},
),
tester,
);
expect(find.byType(Text), findsAtLeast(2));
});
testWidgets('handles pictureUrl parameter', (tester) async {
await mountWidget(
WnProfileSwitcherItem(
pubkey: testPubkeyA,
displayName: 'Alice',
pictureUrl: 'https://example.com/avatar.png',
onTap: () {},
),
tester,
);
expect(find.text('Alice'), findsOneWidget);
});
testWidgets('applies background color when selected', (tester) async {
await mountWidget(
WnProfileSwitcherItem(
pubkey: testPubkeyA,
displayName: 'Alice',
isSelected: true,
onTap: () {},
),
tester,
);
final container = tester.widget<Container>(
find
.ancestor(
of: find.text('Alice'),
matching: find.byType(Container),
)
.first,
);
expect(container.decoration, isNotNull);
});
testWidgets('renders with different pubkeys', (tester) async {
await mountWidget(
Column(
children: [
WnProfileSwitcherItem(
pubkey: testPubkeyA,
displayName: 'Alice',
onTap: () {},
),
WnProfileSwitcherItem(
pubkey: testPubkeyB,
displayName: 'Bob',
onTap: () {},
),
],
),
tester,
);
expect(find.text('Alice'), findsOneWidget);
expect(find.text('Bob'), findsOneWidget);
});
testWidgets('handles long display names with ellipsis', (tester) async {
await mountWidget(
SizedBox(
width: 200,
child: WnProfileSwitcherItem(
pubkey: testPubkeyA,
displayName: 'This is a very long display name that should be truncated',
onTap: () {},
),
),
tester,
);
final textWidget = tester.widget<Text>(
find.text(
'This is a very long display name that should be truncated',
),
);
expect(textWidget.overflow, TextOverflow.ellipsis);
});
});
}
@@ -0,0 +1,273 @@
import 'package:flutter/material.dart';
import 'package:whitenoise/theme.dart';
import 'package:whitenoise/widgets/wn_profile_switcher_item.dart';
import 'package:widgetbook/widgetbook.dart';
import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook;
class WnProfileSwitcherItemStory extends StatelessWidget {
const WnProfileSwitcherItemStory({super.key});
@override
Widget build(BuildContext context) {
return const SizedBox.shrink();
}
}
const _samplePubkeyA =
'npub1zuuajd7u3sx8xu92yav9jwxpr839cs0kc3q6t56vd5u9q033xmhsk6c2uc';
const _samplePubkeyB =
'npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6';
const _samplePubkeyC =
'npub10elfcs4fr0l0r8af98jlmgdh9c8tcxjvz9qkw038js35mp4dma8qzvjptg';
@widgetbook.UseCase(
name: 'Profile Switcher Item',
type: WnProfileSwitcherItemStory,
)
Widget wnProfileSwitcherItemShowcase(BuildContext context) {
return Scaffold(
backgroundColor: context.colors.backgroundPrimary,
body: ListView(
padding: const EdgeInsets.all(24),
children: [
Text(
'Playground',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w600,
color: context.colors.backgroundContentPrimary,
),
),
const SizedBox(height: 8),
Text(
'Use the knobs panel to customize this profile switcher item.',
style: TextStyle(
fontSize: 14,
color: context.colors.backgroundContentSecondary,
),
),
const SizedBox(height: 16),
Align(
alignment: Alignment.centerLeft,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 375),
child: _InteractiveProfileSwitcherItem(context: context),
),
),
const SizedBox(height: 32),
Divider(color: context.colors.borderTertiary),
const SizedBox(height: 24),
_buildSection(
context,
'States',
'Profile switcher item can be in different states: default or selected.',
[
_ProfileSwitcherItemExample(
label: 'Default',
child: WnProfileSwitcherItem(
pubkey: _samplePubkeyA,
displayName: 'Marina Hofmann',
isSelected: false,
onTap: () {},
),
),
_ProfileSwitcherItemExample(
label: 'Selected (Active)',
child: WnProfileSwitcherItem(
pubkey: _samplePubkeyA,
displayName: 'Marina Hofmann',
isSelected: true,
onTap: () {},
),
),
],
),
const SizedBox(height: 32),
_buildSection(
context,
'Content Variations',
'Profile switcher item with different content configurations.',
[
_ProfileSwitcherItemExample(
label: 'With Display Name',
child: WnProfileSwitcherItem(
pubkey: _samplePubkeyA,
displayName: 'Marina Hofmann',
onTap: () {},
),
),
_ProfileSwitcherItemExample(
label: 'Without Display Name',
child: WnProfileSwitcherItem(
pubkey: _samplePubkeyA,
onTap: () {},
),
),
_ProfileSwitcherItemExample(
label: 'With Long Name',
child: WnProfileSwitcherItem(
pubkey: _samplePubkeyB,
displayName:
'This Is A Very Long Display Name That Should Truncate',
onTap: () {},
),
),
],
),
const SizedBox(height: 32),
_buildSection(
context,
'List Example',
'Multiple profile switcher items in a list layout.',
[_ProfileListExample()],
),
],
),
);
}
Widget _buildSection(
BuildContext context,
String title,
String description,
List<Widget> children,
) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: context.colors.backgroundContentPrimary,
),
),
const SizedBox(height: 4),
Text(
description,
style: TextStyle(
fontSize: 13,
color: context.colors.backgroundContentSecondary,
),
),
const SizedBox(height: 16),
Wrap(spacing: 24, runSpacing: 24, children: children),
],
);
}
class _ProfileSwitcherItemExample extends StatelessWidget {
const _ProfileSwitcherItemExample({required this.label, required this.child});
final String label;
final Widget child;
@override
Widget build(BuildContext context) {
return SizedBox(
width: 368,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: context.colors.backgroundContentSecondary,
),
),
const SizedBox(height: 8),
child,
],
),
);
}
}
class _ProfileListExample extends StatefulWidget {
@override
State<_ProfileListExample> createState() => _ProfileListExampleState();
}
class _ProfileListExampleState extends State<_ProfileListExample> {
String _selectedPubkey = _samplePubkeyA;
@override
Widget build(BuildContext context) {
return SizedBox(
width: 368,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Selectable List',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: context.colors.backgroundContentSecondary,
),
),
const SizedBox(height: 8),
Column(
children: [
WnProfileSwitcherItem(
pubkey: _samplePubkeyA,
displayName: 'Marina Hofmann',
isSelected: _selectedPubkey == _samplePubkeyA,
onTap: () => setState(() => _selectedPubkey = _samplePubkeyA),
),
const SizedBox(height: 8),
WnProfileSwitcherItem(
pubkey: _samplePubkeyB,
displayName: 'Bob Smith',
isSelected: _selectedPubkey == _samplePubkeyB,
onTap: () => setState(() => _selectedPubkey = _samplePubkeyB),
),
const SizedBox(height: 8),
WnProfileSwitcherItem(
pubkey: _samplePubkeyC,
displayName: 'Charlie Brown',
isSelected: _selectedPubkey == _samplePubkeyC,
onTap: () => setState(() => _selectedPubkey = _samplePubkeyC),
),
],
),
],
),
);
}
}
class _InteractiveProfileSwitcherItem extends StatefulWidget {
const _InteractiveProfileSwitcherItem({required this.context});
final BuildContext context;
@override
State<_InteractiveProfileSwitcherItem> createState() =>
_InteractiveProfileSwitcherItemState();
}
class _InteractiveProfileSwitcherItemState
extends State<_InteractiveProfileSwitcherItem> {
@override
Widget build(BuildContext context) {
final displayName = widget.context.knobs.stringOrNull(
label: 'Display Name',
initialValue: 'Marina Hofmann',
);
final isSelected = widget.context.knobs.boolean(
label: 'Selected',
initialValue: false,
);
return WnProfileSwitcherItem(
pubkey: _samplePubkeyA,
displayName: displayName,
isSelected: isSelected,
onTap: () {},
);
}
}
+12
View File
@@ -42,6 +42,8 @@ import 'package:whitenoise_widgetbook/components/wn_avatar.dart'
as _whitenoise_widgetbook_components_wn_avatar;
import 'package:whitenoise_widgetbook/components/wn_copy_card.dart'
as _whitenoise_widgetbook_components_wn_copy_card;
import 'package:whitenoise_widgetbook/components/wn_profile_switcher_item.dart'
as _whitenoise_widgetbook_components_wn_profile_switcher_item;
import 'package:whitenoise_widgetbook/foundations/semantic_colors.dart'
as _whitenoise_widgetbook_foundations_semantic_colors;
import 'package:whitenoise_widgetbook/foundations/typography.dart'
@@ -217,6 +219,16 @@ final directories = <_widgetbook.WidgetbookNode>[
),
],
),
_widgetbook.WidgetbookComponent(
name: 'WnProfileSwitcherItemStory',
useCases: [
_widgetbook.WidgetbookUseCase(
name: 'Profile Switcher Item',
builder: _whitenoise_widgetbook_components_wn_profile_switcher_item
.wnProfileSwitcherItemShowcase,
),
],
),
_widgetbook.WidgetbookComponent(
name: 'WnScrollEdgeEffectStory',
useCases: [