mirror of
https://github.com/block/buzz.git
synced 2026-10-30 22:11:36 +00:00
## Summary Use UIKit navigation bars across iOS pages, with large titles that collapse into the toolbar on scroll and a light native blur. Home uses the active community name as its title and shows community and profile avatars with centered emoji and stable loading, without flashing back to icons. Channel headers retain the member count, tap-to-settings title, and Huddle action. Their ultra-thin native material has a soft lower edge and stays visible at the timeline bottom. The hidden title-layout scroll view has its automatic edge effects disabled so it cannot add a second backdrop. | Expanded Home | Scrolled Home | Settings | | --- | --- | --- | | <img src="https://raw.githubusercontent.com/block/buzz/47a77d3ff1d30fc74f7488c1a4427e41bbb85c4e/pr-8052--home.png" alt="home" width="240"> | <img src="https://raw.githubusercontent.com/block/buzz/47a77d3ff1d30fc74f7488c1a4427e41bbb85c4e/pr-8052--scrolled.png" alt="scrolled" width="240"> | <img src="https://raw.githubusercontent.com/block/buzz/47a77d3ff1d30fc74f7488c1a4427e41bbb85c4e/pr-8052--settings.png" alt="settings" width="240"> | Channel title, member count, and Huddle control: <img src="https://raw.githubusercontent.com/block/buzz/47a77d3ff1d30fc74f7488c1a4427e41bbb85c4e/pr-8052--channel.png" alt="Channel navigation" width="280"> ### Related issue No matching open issue found. ### Testing - Added causal regressions for Profile/Edit Photo native titles, metrics-only collapse changes, and native-header suppression throughout channel/thread reaction and action backdrops. Each regression fails when its corresponding fix is removed. - Mobile formatting and analysis passed. Full mobile test rerun passed: 2,622 tests, 4 skipped, plus 3 push-disabled build tests. - Native iPhone Air regression passed: repeated title expansion/collapse and scroll material behavior. - Navigation and blur were accepted on the connected iPhone using the existing profile. Simulator snapshots use real community data. The subsequent review fixes have widget coverage; fresh physical-device verification of Profile states, Dynamic Type changes, and message backdrops is pending. - Full repository `just ci` was blocked by local disk exhaustion while compiling Rust dependencies. --------- Signed-off-by: kenny lopez <klopez4212@gmail.com>
344 lines
11 KiB
Dart
344 lines
11 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
|
|
|
import '../../shared/identity_names/identity_names_provider.dart';
|
|
import '../../shared/relay/relay.dart';
|
|
import '../../shared/theme/theme.dart';
|
|
import '../../shared/widgets/filter_chip_bar.dart';
|
|
import '../../shared/widgets/bee_refresh_indicator.dart';
|
|
import '../../shared/widgets/frosted_app_bar.dart';
|
|
import '../../shared/widgets/frosted_scaffold.dart';
|
|
import 'agent_activity_card.dart';
|
|
import 'compose_note_page.dart';
|
|
import 'note_card.dart';
|
|
import 'pulse_models.dart';
|
|
import 'pulse_provider.dart';
|
|
|
|
enum PulseTab { everyone, following, liked, agents, mine }
|
|
|
|
class PulsePage extends HookConsumerWidget {
|
|
const PulsePage({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final active = useState(PulseTab.everyone);
|
|
final isIos = defaultTargetPlatform == TargetPlatform.iOS;
|
|
final currentPubkey = ref.watch(myPubkeyProvider);
|
|
final contactsAsync = currentPubkey == null
|
|
? const AsyncValue<List<ContactEntry>>.data([])
|
|
: ref.watch(contactListProvider(currentPubkey));
|
|
final contacts = contactsAsync.asData?.value ?? const <ContactEntry>[];
|
|
final contactPubkeys = contacts.map((c) => c.pubkey).toList();
|
|
final contactSet = contactPubkeys.toSet();
|
|
final agentPubkeys =
|
|
ref.watch(agentPubkeysProvider).asData?.value.toSet() ?? {};
|
|
|
|
final notesAsync = switch (active.value) {
|
|
PulseTab.everyone => ref.watch(globalNotesProvider),
|
|
PulseTab.following => ref.watch(
|
|
notesTimelineProvider(pulseKeyFor(contactPubkeys)),
|
|
),
|
|
PulseTab.liked => ref.watch(likedNotesProvider),
|
|
PulseTab.agents => ref.watch(agentNotesProvider),
|
|
PulseTab.mine =>
|
|
currentPubkey == null
|
|
? const AsyncValue<List<UserNote>>.data([])
|
|
: ref.watch(notesTimelineProvider(pulseKeyFor([currentPubkey]))),
|
|
};
|
|
|
|
final visibleNotes = notesAsync.asData?.value ?? const <UserNote>[];
|
|
if (visibleNotes.isNotEmpty) preloadPulseProfiles(ref, visibleNotes);
|
|
final notesKey = pulseKeyFor(visibleNotes.map((note) => note.id));
|
|
final reactions = ref.watch(noteReactionsProvider(notesKey));
|
|
final reactionMap =
|
|
reactions.asData?.value ?? const <String, PulseReactionState>{};
|
|
|
|
return FrostedScaffold(
|
|
// Reset the native collapse state together with the new timeline.
|
|
key: isIos ? ValueKey(active.value) : null,
|
|
nativePinnedBody: true,
|
|
resizeToAvoidBottomInset: true,
|
|
appBar: const FrostedAppBar(nativeLargeTitle: true, title: Text('Pulse')),
|
|
floatingActionButton: FloatingActionButton(
|
|
heroTag: 'pulse-compose-fab',
|
|
onPressed: () => Navigator.of(context).push(
|
|
MaterialPageRoute<void>(builder: (_) => const ComposeNotePage()),
|
|
),
|
|
tooltip: 'New note',
|
|
shape: const CircleBorder(),
|
|
child: const Icon(LucideIcons.plus),
|
|
),
|
|
body: Column(
|
|
children: [
|
|
SizedBox(height: frostedAppBarHeight(context)),
|
|
FilterChipBar<PulseTab>(
|
|
selected: active.value,
|
|
onSelected: (tab) => active.value = tab,
|
|
items: const [
|
|
FilterChipItem(
|
|
id: PulseTab.everyone,
|
|
label: 'Everyone',
|
|
icon: LucideIcons.radio,
|
|
),
|
|
FilterChipItem(
|
|
id: PulseTab.following,
|
|
label: 'Following',
|
|
icon: LucideIcons.users,
|
|
),
|
|
FilterChipItem(
|
|
id: PulseTab.liked,
|
|
label: 'Liked',
|
|
icon: LucideIcons.heart,
|
|
),
|
|
FilterChipItem(
|
|
id: PulseTab.agents,
|
|
label: 'Agents',
|
|
icon: LucideIcons.bot,
|
|
),
|
|
FilterChipItem(
|
|
id: PulseTab.mine,
|
|
label: 'Mine',
|
|
icon: LucideIcons.user,
|
|
),
|
|
],
|
|
),
|
|
Expanded(
|
|
child: BeeRefreshIndicator(
|
|
onRefresh: () async => _refresh(ref, active.value, currentPubkey),
|
|
child: _PulseBody(
|
|
tab: active.value,
|
|
notesAsync: notesAsync,
|
|
reactions: reactionMap,
|
|
agentPubkeys: agentPubkeys,
|
|
contactPubkeys: contactSet,
|
|
currentPubkey: currentPubkey,
|
|
onReactionChanged: () =>
|
|
ref.invalidate(noteReactionsProvider(notesKey)),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _refresh(
|
|
WidgetRef ref,
|
|
PulseTab tab,
|
|
String? currentPubkey,
|
|
) async {
|
|
ref.invalidate(globalNotesProvider);
|
|
ref.invalidate(likedNotesProvider);
|
|
ref.invalidate(agentPubkeysProvider);
|
|
ref.invalidate(agentNotesProvider);
|
|
if (currentPubkey != null) {
|
|
ref.invalidate(contactListProvider(currentPubkey));
|
|
}
|
|
}
|
|
}
|
|
|
|
class _PulseBody extends ConsumerWidget {
|
|
final PulseTab tab;
|
|
final AsyncValue<List<UserNote>> notesAsync;
|
|
final Map<String, PulseReactionState> reactions;
|
|
final Set<String> agentPubkeys;
|
|
final Set<String> contactPubkeys;
|
|
final String? currentPubkey;
|
|
final VoidCallback onReactionChanged;
|
|
|
|
const _PulseBody({
|
|
required this.tab,
|
|
required this.notesAsync,
|
|
required this.reactions,
|
|
required this.agentPubkeys,
|
|
required this.contactPubkeys,
|
|
required this.currentPubkey,
|
|
required this.onReactionChanged,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
return notesAsync.when(
|
|
loading: () => const _TimelineSkeleton(),
|
|
error: (_, _) => _MessageListShell(
|
|
child: _EmptyState(
|
|
icon: LucideIcons.circleAlert,
|
|
message: 'Could not load Pulse. Pull to try again.',
|
|
),
|
|
),
|
|
data: (notes) {
|
|
if (notes.isEmpty) {
|
|
return _MessageListShell(
|
|
child: _EmptyState(message: _emptyMessage(tab)),
|
|
);
|
|
}
|
|
// Everyone the timeline names — authors, reply targets and mentions
|
|
// — is one comparison context, so two same-name targets on
|
|
// different notes are still told apart.
|
|
final names = watchIdentityNames(
|
|
ref,
|
|
pulseNamedIdentities(notes),
|
|
agentPubkeys: tab == PulseTab.agents
|
|
? {for (final note in notes) note.pubkey.toLowerCase()}
|
|
: agentPubkeys,
|
|
);
|
|
if (tab == PulseTab.agents) {
|
|
final groups = groupAgentNotes(notes);
|
|
return ListView.separated(
|
|
padding: const EdgeInsets.fromLTRB(
|
|
Grid.gutter,
|
|
Grid.xxs,
|
|
Grid.gutter,
|
|
Grid.xs,
|
|
),
|
|
itemCount: groups.length,
|
|
separatorBuilder: (_, _) => const _NoteDivider(),
|
|
itemBuilder: (context, index) => AgentActivityCard(
|
|
group: groups[index],
|
|
reactions: reactions,
|
|
names: names,
|
|
onReactionChanged: onReactionChanged,
|
|
),
|
|
);
|
|
}
|
|
return ListView.separated(
|
|
padding: const EdgeInsets.fromLTRB(
|
|
Grid.gutter,
|
|
Grid.xxs,
|
|
Grid.gutter,
|
|
Grid.xs,
|
|
),
|
|
itemCount: notes.length,
|
|
separatorBuilder: (_, _) => const _NoteDivider(),
|
|
itemBuilder: (context, index) {
|
|
final note = notes[index];
|
|
return NoteCard(
|
|
note: note,
|
|
reaction:
|
|
reactions[note.id] ??
|
|
const PulseReactionState(
|
|
count: 0,
|
|
reactedByCurrentUser: false,
|
|
),
|
|
isAgent: agentPubkeys.contains(note.pubkey),
|
|
names: names,
|
|
isFollowing: contactPubkeys.contains(note.pubkey),
|
|
canFollow:
|
|
currentPubkey != null &&
|
|
currentPubkey!.toLowerCase() != note.pubkey.toLowerCase(),
|
|
onReactionChanged: onReactionChanged,
|
|
onFollowChanged: (_) {
|
|
if (currentPubkey != null) {
|
|
ref.invalidate(contactListProvider(currentPubkey!));
|
|
}
|
|
},
|
|
);
|
|
},
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
String _emptyMessage(PulseTab tab) => switch (tab) {
|
|
PulseTab.everyone => 'No notes yet. Be the first pulse.',
|
|
PulseTab.following => 'Follow people to build your Pulse timeline.',
|
|
PulseTab.liked => 'Heart notes to save them here.',
|
|
PulseTab.agents =>
|
|
'No agent notes yet. Agents post here when they publish.',
|
|
PulseTab.mine => 'Your notes will show up here.',
|
|
};
|
|
}
|
|
|
|
class _MessageListShell extends StatelessWidget {
|
|
final Widget child;
|
|
|
|
const _MessageListShell({required this.child});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return ListView(
|
|
padding: const EdgeInsets.all(Grid.xs),
|
|
children: [SizedBox(height: 260, child: Center(child: child))],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _EmptyState extends StatelessWidget {
|
|
final IconData icon;
|
|
final String message;
|
|
|
|
const _EmptyState({this.icon = LucideIcons.radio, required this.message});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.all(Grid.lg),
|
|
decoration: BoxDecoration(
|
|
border: Border.all(color: context.colors.outlineVariant),
|
|
borderRadius: BorderRadius.circular(Radii.lg),
|
|
),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(icon, color: context.colors.onSurfaceVariant),
|
|
const SizedBox(height: Grid.xs),
|
|
Text(
|
|
message,
|
|
textAlign: TextAlign.center,
|
|
style: context.textTheme.bodyMedium?.copyWith(
|
|
color: context.colors.onSurfaceVariant,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _TimelineSkeleton extends StatelessWidget {
|
|
const _TimelineSkeleton();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return ListView.separated(
|
|
padding: const EdgeInsets.fromLTRB(
|
|
Grid.gutter,
|
|
Grid.xxs,
|
|
Grid.gutter,
|
|
Grid.xs,
|
|
),
|
|
itemCount: 5,
|
|
separatorBuilder: (_, _) => const _NoteDivider(),
|
|
itemBuilder: (_, _) => Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: Grid.twelve),
|
|
child: Container(
|
|
height: 64,
|
|
decoration: BoxDecoration(
|
|
color: context.colors.surfaceContainerHighest.withValues(
|
|
alpha: 0.55,
|
|
),
|
|
borderRadius: BorderRadius.circular(Radii.md),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _NoteDivider extends StatelessWidget {
|
|
const _NoteDivider();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Divider(
|
|
height: 1,
|
|
thickness: 1,
|
|
color: context.colors.outlineVariant.withValues(alpha: 0.5),
|
|
);
|
|
}
|
|
}
|