mirror of
https://github.com/block/buzz.git
synced 2026-11-11 20:01:09 +00:00
## Summary Follow-up to [#9213 Activity filters UI](https://github.com/block/buzz/pull/9213), from its [last Codex finding](https://github.com/block/buzz/pull/9213#discussion_r4497322772). With Threads, Mentions or a People/Agents filter active, an Activity row holds only part of its conversation. Marking it read advanced the `thread:<root>` (or channel) marker through the row's latest event, which also read replies the filter hid. For example, reading a person's reply in People also read an older agent reply in the same thread. Filtered rows now advance the synced thread or channel marker through every event that is read, stopping just before the first unread event the filter hides, and write local `msg:` marks only for shown events past that point. Once the hidden event is read too, the marker catches up and syncs both. This also fixes filtered DM rows from #9213, whose `msg:`-only reads never synced to other devices. Unfiltered rows are unchanged. ## Testing - New widget test: "reading a thread row under People leaves hidden agent replies unread". It fails without the fix, and checks the synced marker stops short of a hidden unread reply, then advances fully once nothing hidden is unread. - `flutter test test/features/activity/` (136 pass) and `flutter analyze` on the activity feature. ## Before merge - [x] Automated simulator run (iPhone 17 Pro sim, iOS 27, re-run on d17f79a; the catch-up change in28b927eis covered by the widget test): with Agents selected, marked a channel mention row unread then read; it stays read under Anyone and after relaunch. Screenshots are in Buzz (internal data). The test account has no thread mixing agent and human replies, so the hidden-reply case is covered by the widget test. - [x] Main vs PR simulator comparison of a filtered thread read with a hidden unread reply (iPhone 17 Pro sim, iOS 27; main at [65e8643](https://github.com/block/buzz/commit/65e864301d), PR at [b2414cf](b2414cf6d8)). Neither test account has a real thread with both a person and an agent mention, so this runs the Activity page on the simulator with seeded events through the widget-test harness (`flutter run` of the test). The thread has an older agent mention and a newer person mention, both unread. The steps: choose People, swipe the person row to read it, switch to Agents, then Anyone. Left is main, right is this PR. - After the read, the person row is read under People on both. - Under Agents, main shows the older agent reply as read (the thread marker moved to the person reply's timestamp). The PR keeps it unread (marker stops one second before the agent reply).  - Under Anyone, the thread row is read on main and still unread on the PR, because its agent reply is unread.  🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
619 lines
23 KiB
Dart
619 lines
23 KiB
Dart
import 'package:buzz/shared/widgets/feedback_snackbar.dart';
|
||
|
||
import 'dart:async';
|
||
import 'dart:math' show max;
|
||
|
||
import '../../shared/widgets/ios_message_create_button.dart';
|
||
import '../../shared/widgets/ios_glass_navigation_button.dart';
|
||
import '../../shared/widgets/ios_scroll_to_top.dart';
|
||
import '../../shared/widgets/top_edge_fade.dart';
|
||
|
||
import 'package:flutter/foundation.dart';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter/services.dart';
|
||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||
import 'package:buzz/shared/theme/buzz_icons.dart';
|
||
|
||
import '../../shared/identity_names/identity_names_provider.dart';
|
||
import '../../shared/mentions/agent_identity_provider.dart';
|
||
import '../../shared/mentions/mention_tags.dart';
|
||
import '../../shared/relay/relay.dart';
|
||
import '../../shared/theme/theme.dart';
|
||
import '../../shared/utils/string_utils.dart';
|
||
import '../../shared/widgets/sheet_action_section.dart';
|
||
import '../../shared/widgets/avatar_image.dart';
|
||
import '../../shared/widgets/anchored_popover_menu.dart';
|
||
import '../../shared/widgets/bee_refresh_indicator.dart';
|
||
import '../../shared/widgets/buzz_loading_indicator.dart';
|
||
import '../../shared/widgets/message_author_meta.dart';
|
||
import '../../shared/widgets/modal_presentation.dart';
|
||
import '../channels/channel.dart';
|
||
import '../channels/channel_detail_page.dart';
|
||
import '../channels/channel_identity_names_provider.dart';
|
||
import '../channels/channel_management_provider.dart';
|
||
import '../channels/channels_provider.dart';
|
||
import '../channels/dm_channel_labels.dart';
|
||
import '../channels/message_content.dart';
|
||
import '../../shared/read_state/read_state_format.dart';
|
||
import '../../shared/reminders/remind_me_later_sheet.dart';
|
||
import '../../shared/reminders/reminder_service.dart'
|
||
show ReminderTarget, reminderServiceProvider;
|
||
import '../channels/thread_follows/thread_follows_provider.dart';
|
||
import '../../shared/read_state/read_state_provider.dart';
|
||
import '../../shared/profile/user_cache_provider.dart';
|
||
import '../../shared/profile/user_profile.dart';
|
||
import 'activity_provider.dart';
|
||
import 'compose_drafts_provider.dart';
|
||
import 'dm_resurface.dart';
|
||
import 'feed_item.dart';
|
||
import 'inbox_item.dart';
|
||
import 'inbox_local_state_provider.dart';
|
||
import 'inbox_read_state.dart';
|
||
import 'reminders_provider.dart' hide ReminderTarget;
|
||
|
||
part 'activity_page/header_actions.dart';
|
||
part 'activity_page/inbox_row.dart';
|
||
part 'activity_page/lists.dart';
|
||
part 'activity_page/status_views.dart';
|
||
|
||
EdgeInsets _activityScrollPadding(
|
||
BuildContext context, {
|
||
double horizontal = 0,
|
||
double top = Grid.xxs,
|
||
double bottom = Grid.xxs,
|
||
}) => EdgeInsets.fromLTRB(
|
||
horizontal,
|
||
top,
|
||
horizontal,
|
||
MediaQuery.paddingOf(context).bottom + bottom,
|
||
);
|
||
|
||
/// Conversation-oriented Activity inbox.
|
||
///
|
||
/// Matches desktop's Home inbox item design and semantics (see
|
||
/// `desktop/src/features/home/ui/InboxListPane.tsx`): full sender avatar +
|
||
/// name, contextual "Mentioned in #channel"-style label, unread dot + time,
|
||
/// message preview — while keeping mobile's list → canonical destination
|
||
/// navigation. Row taps deep-link to the represented message (oldest unread
|
||
/// for grouped conversations) rather than just opening the channel.
|
||
const _activityTypeOptions = {
|
||
InboxFilter.all: 'All',
|
||
InboxFilter.thread: 'Threads',
|
||
InboxFilter.mention: 'Mentions',
|
||
InboxFilter.dm: 'DMs',
|
||
InboxFilter.reminders: 'Reminders',
|
||
InboxFilter.drafts: 'Drafts',
|
||
};
|
||
|
||
class ActivityPage extends HookConsumerWidget {
|
||
const ActivityPage({this.tabReselection, super.key});
|
||
|
||
/// Notifies this page when its already-selected tab is tapped again.
|
||
final ValueListenable<int>? tabReselection;
|
||
|
||
@override
|
||
Widget build(BuildContext context, WidgetRef ref) {
|
||
final feedAsync = ref.watch(activityProvider);
|
||
final channelsAsync = ref.watch(channelsProvider);
|
||
final filter = useState(InboxFilter.all);
|
||
final unreadOnly = useState(false);
|
||
final sender = useState('Anyone');
|
||
final agents = ref.watch(knownAgentPubkeysProvider);
|
||
final profiles = ref.watch(userCacheProvider);
|
||
final scrollController = useScrollController();
|
||
final reducedMotion = MediaQuery.disableAnimationsOf(context);
|
||
useEffect(() {
|
||
final tabReselection = this.tabReselection;
|
||
if (tabReselection == null) return null;
|
||
|
||
void scrollToTop() {
|
||
if (!scrollController.hasClients) return;
|
||
final position = scrollController.position;
|
||
if (position.pixels <= position.minScrollExtent + 0.5) return;
|
||
if (reducedMotion) {
|
||
scrollController.jumpTo(position.minScrollExtent);
|
||
return;
|
||
}
|
||
unawaited(
|
||
scrollController.animateTo(
|
||
position.minScrollExtent,
|
||
duration: const Duration(milliseconds: 260),
|
||
curve: Curves.easeOutCubic,
|
||
),
|
||
);
|
||
}
|
||
|
||
tabReselection.addListener(scrollToTop);
|
||
return () => tabReselection.removeListener(scrollToTop);
|
||
}, [tabReselection, scrollController, reducedMotion]);
|
||
final headerTitleStyle = context.textTheme.titleMedium?.copyWith(
|
||
fontSize: 22,
|
||
fontWeight: FontWeight.w600,
|
||
color: navigationPrimaryForeground(context),
|
||
);
|
||
final filterHeight =
|
||
max(48.0, MediaQuery.textScalerOf(context).scale(16) * 1.4 + 16) +
|
||
Grid.xxs;
|
||
|
||
final readState = ref.watch(readStateProvider);
|
||
final localState = ref.watch(inboxLocalStateProvider);
|
||
final drafts = ref.watch(composeDraftsProvider);
|
||
final allItems = ref.watch(inboxItemsProvider);
|
||
final myPk = ref.watch(myPubkeyProvider);
|
||
|
||
// Cache the last non-empty feed so the UI doesn't flash on rebuild.
|
||
final hasLoadedOnce = useRef(false);
|
||
if (feedAsync.hasValue) hasLoadedOnce.value = true;
|
||
|
||
final channels = channelsAsync.asData?.value ?? const <Channel>[];
|
||
final channelById = {for (final c in channels) c.id: c};
|
||
|
||
int? markerOf(String contextId) => readState.effectiveTimestamp(contextId);
|
||
bool isDone(InboxItem item) => isInboxItemDone(
|
||
item,
|
||
markerOf: markerOf,
|
||
localUnreadOverrides: localState.unreadIds,
|
||
localDoneSet: localState.doneIds,
|
||
);
|
||
|
||
// Rows mark channel bot members as agents, so the sender filter does too.
|
||
final channelAgents = <String, Set<String>>{};
|
||
bool includesSender(FeedItem e) {
|
||
if (sender.value == 'Anyone') return true;
|
||
final pk = e.pubkey.toLowerCase();
|
||
final channelId = e.channelId;
|
||
final known = channelId == null
|
||
? agents
|
||
: channelAgents.putIfAbsent(
|
||
channelId,
|
||
() => ref.watch(agentMentionPubkeysProvider(channelId)),
|
||
);
|
||
final isAgent = known.contains(pk) || profiles[pk]?.ownerPubkey != null;
|
||
return sender.value == 'Agents' ? isAgent : !isAgent;
|
||
}
|
||
|
||
// Mentions, Threads and sender filters apply to each event before
|
||
// regrouping, so a row shows the matching activity rather than a later
|
||
// unrelated message from the same conversation.
|
||
final candidates = buildInboxItems(
|
||
allItems
|
||
.expand((i) => i.groupItems)
|
||
.where(
|
||
(e) =>
|
||
(filter.value != InboxFilter.mention ||
|
||
e.category == 'mention') &&
|
||
(filter.value != InboxFilter.thread || isThreadReply(e.tags)) &&
|
||
includesSender(e),
|
||
),
|
||
isDmChannel: (id) =>
|
||
(channelById[id]?.isDm ?? false) ||
|
||
ref.read(channelsProvider.notifier).hiddenDmIds.contains(id),
|
||
);
|
||
final rowsArePartial =
|
||
filter.value == InboxFilter.thread ||
|
||
filter.value == InboxFilter.mention ||
|
||
sender.value != 'Anyone';
|
||
final visibleItems = [
|
||
for (final item in candidates)
|
||
if (matchesInboxFilter(item, filter.value) &&
|
||
(!unreadOnly.value || !isDone(item)))
|
||
item,
|
||
];
|
||
|
||
// Preload sender profiles for visible rows, and for every author while a
|
||
// sender filter needs their agent ownership.
|
||
final preloadPubkeys = {
|
||
if (sender.value != 'Anyone')
|
||
for (final item in allItems)
|
||
for (final e in item.groupItems) e.pubkey.toLowerCase(),
|
||
for (final item in visibleItems) item.item.pubkey.toLowerCase(),
|
||
for (final item in visibleItems)
|
||
...mentionedPubkeysFromTags(item.item.tags),
|
||
}.toList()..sort();
|
||
final preloadPubkeysKey = preloadPubkeys.join('\u0000');
|
||
useEffect(() {
|
||
ref.read(userCacheProvider.notifier).preload(preloadPubkeys);
|
||
return null;
|
||
}, [preloadPubkeysKey]);
|
||
|
||
// A filtered row holds only part of its conversation. Its synced thread
|
||
// or channel marker is one watermark, so advance it through every event
|
||
// that is now read (shown here, or already read) and stop just before the
|
||
// first unread event the filter hides. Shown events past that point get
|
||
// local per-message marks until the hidden one is read too.
|
||
void markPartialRowRead(
|
||
InboxItem item,
|
||
String channelId, {
|
||
required bool isDmRow,
|
||
}) {
|
||
final notifier = ref.read(readStateProvider.notifier);
|
||
final shown = {
|
||
for (final e in [item.item, ...item.groupItems]) e.id: e,
|
||
};
|
||
final rootId = isDmRow ? null : item.threadRootId;
|
||
bool inContext(FeedItem e) =>
|
||
e.channelId == channelId &&
|
||
(rootId == null ||
|
||
(isThreadReply(e.tags) &&
|
||
threadReferenceOf(e.tags).rootId == rootId));
|
||
final events = {
|
||
for (final e in [
|
||
...allItems.expand((i) => i.groupItems),
|
||
...shown.values,
|
||
])
|
||
if (inContext(e)) e.id: e,
|
||
}.values.toList()..sort((a, b) => a.createdAt.compareTo(b.createdAt));
|
||
int? cap;
|
||
for (final e in events) {
|
||
if (!shown.containsKey(e.id) &&
|
||
!isInboxEventRead(e, markerOf: markerOf)) {
|
||
cap = e.createdAt - 1;
|
||
break;
|
||
}
|
||
cap = e.createdAt;
|
||
}
|
||
if (cap != null) {
|
||
notifier.markContextRead(
|
||
rootId == null ? channelId : threadContextKey(rootId),
|
||
cap,
|
||
);
|
||
}
|
||
for (final e in shown.values) {
|
||
if (inContext(e) && cap != null && e.createdAt <= cap) continue;
|
||
notifier.markContextRead(msgContextKey(e.id), e.createdAt);
|
||
}
|
||
}
|
||
|
||
void markItemRead(InboxItem item) {
|
||
final notifier = ref.read(readStateProvider.notifier);
|
||
ref
|
||
.read(inboxLocalStateProvider.notifier)
|
||
.clearUnread(groupedInboxItemIds(item));
|
||
final isDmRow = item.conversationId.startsWith('dm:');
|
||
final partialChannelId = rowsArePartial ? item.item.channelId : null;
|
||
if (partialChannelId != null) {
|
||
markPartialRowRead(item, partialChannelId, isDmRow: isDmRow);
|
||
return;
|
||
}
|
||
// A DM row can group replies under several thread roots; its channel
|
||
// marker reads all of them.
|
||
final threadRootId = isDmRow ? null : item.threadRootId;
|
||
if (threadRootId != null) {
|
||
notifier.markContextRead(
|
||
threadContextKey(threadRootId),
|
||
item.latestActivityAt,
|
||
);
|
||
final channelRead = groupedChannelReadTimestamp(item);
|
||
if (channelRead != null) {
|
||
notifier.markContextRead(
|
||
channelRead.channelId,
|
||
channelRead.timestamp,
|
||
);
|
||
}
|
||
return;
|
||
}
|
||
final channelId = item.item.channelId;
|
||
if (channelId != null) {
|
||
notifier.markContextRead(channelId, item.latestActivityAt);
|
||
ref
|
||
.read(channelsProvider.notifier)
|
||
.clearObservedUnreadCoveredByRead(channelId, item.latestActivityAt);
|
||
return;
|
||
}
|
||
ref.read(inboxLocalStateProvider.notifier).markDone(item.id);
|
||
}
|
||
|
||
void markItemUnread(InboxItem item) {
|
||
ref
|
||
.read(inboxLocalStateProvider.notifier)
|
||
.markUnread(groupedInboxItemIds(item));
|
||
}
|
||
|
||
Future<void> openItem(InboxItem item) async {
|
||
final channelId = item.item.channelId;
|
||
if (channelId == null) {
|
||
ScaffoldMessenger.maybeOf(context)?.showFeedbackSnackBar(
|
||
const SnackBar(content: Text('No linked channel')),
|
||
kind: ToastKind.error,
|
||
);
|
||
return;
|
||
}
|
||
var channel = channelById[channelId];
|
||
if (channel == null &&
|
||
myPk != null &&
|
||
ref.read(channelsProvider.notifier).hiddenDmIds.contains(channelId)) {
|
||
final expectedPubkey = myPk.toLowerCase();
|
||
final expectedRelayUrl = ref.read(relayConfigProvider).baseUrl;
|
||
bool isCurrentScope() =>
|
||
context.mounted &&
|
||
ref.read(myPubkeyProvider)?.toLowerCase() == expectedPubkey &&
|
||
ref.read(relayConfigProvider).baseUrl == expectedRelayUrl;
|
||
try {
|
||
final members = await ref.read(
|
||
channelMembersProvider(channelId).future,
|
||
);
|
||
if (!isCurrentScope()) return;
|
||
final peers = dmPeerPubkeysFromMembers(
|
||
members.map((member) => member.pubkey),
|
||
expectedPubkey,
|
||
);
|
||
if (peers.isEmpty) {
|
||
throw StateError('Could not determine the DM membership.');
|
||
}
|
||
final reopened = await ref
|
||
.read(channelActionsProvider)
|
||
.openDm(pubkeys: peers.toList());
|
||
if (!isCurrentScope()) return;
|
||
if (reopened.id != channelId) {
|
||
throw StateError('Relay reopened a different DM conversation.');
|
||
}
|
||
channel = reopened;
|
||
} catch (error) {
|
||
if (!isCurrentScope()) return;
|
||
if (!context.mounted) return;
|
||
ScaffoldMessenger.maybeOf(context)?.showFeedbackSnackBar(
|
||
SnackBar(content: Text('Could not reopen conversation: $error')),
|
||
kind: ToastKind.error,
|
||
);
|
||
return;
|
||
}
|
||
}
|
||
if (channel == null) {
|
||
if (!context.mounted) return;
|
||
ScaffoldMessenger.maybeOf(context)?.showFeedbackSnackBar(
|
||
const SnackBar(content: Text('Channel not found here')),
|
||
kind: ToastKind.error,
|
||
);
|
||
return;
|
||
}
|
||
final resolvedChannel = channel;
|
||
|
||
// Deep-link to the represented message: oldest unread in the group,
|
||
// falling back to the latest event.
|
||
final target = item.deepLinkTarget(
|
||
(event) => isInboxEventRead(event, markerOf: markerOf),
|
||
);
|
||
final thread = threadReferenceOf(target.tags);
|
||
final threadRootId = isBroadcastReply(target.tags)
|
||
? null
|
||
: thread.parentId;
|
||
|
||
if (!context.mounted) return;
|
||
Navigator.of(context).push(
|
||
MaterialPageRoute<void>(
|
||
builder: (_) => ChannelDetailPage(
|
||
channel: resolvedChannel,
|
||
initialMessageId: target.id,
|
||
initialThreadRootId: threadRootId,
|
||
initialThreadRouteBehavior:
|
||
InitialThreadRouteBehavior.replaceCurrentRoute,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
void openDraft(ComposeDraft draft) {
|
||
final channel = channelById[draft.channelId];
|
||
if (channel == null) {
|
||
ScaffoldMessenger.maybeOf(context)?.showFeedbackSnackBar(
|
||
const SnackBar(content: Text('Draft’s channel unavailable')),
|
||
kind: ToastKind.error,
|
||
);
|
||
return;
|
||
}
|
||
Navigator.of(context).push(
|
||
MaterialPageRoute<void>(
|
||
builder: (_) => ChannelDetailPage(
|
||
channel: channel,
|
||
initialThreadRootId: draft.threadHeadId,
|
||
initialThreadRouteBehavior:
|
||
InitialThreadRouteBehavior.replaceCurrentRoute,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
void openReminder(Reminder reminder) {
|
||
final target = reminder.target;
|
||
if (target == null) {
|
||
ScaffoldMessenger.maybeOf(context)?.showFeedbackSnackBar(
|
||
const SnackBar(content: Text('Reminder has no linked message')),
|
||
kind: ToastKind.error,
|
||
);
|
||
return;
|
||
}
|
||
final channel = channelById[target.channelId];
|
||
if (channel == null) {
|
||
ScaffoldMessenger.maybeOf(context)?.showFeedbackSnackBar(
|
||
const SnackBar(content: Text('Reminder’s channel unavailable')),
|
||
kind: ToastKind.error,
|
||
);
|
||
return;
|
||
}
|
||
Navigator.of(context).push(
|
||
MaterialPageRoute<void>(
|
||
builder: (_) => ChannelDetailPage(
|
||
channel: channel,
|
||
initialMessageId: target.eventId,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Future<void> refresh() async {
|
||
await Future.wait([
|
||
ref.read(activityProvider.notifier).refresh(),
|
||
ref.read(remindersProvider.notifier).refresh(),
|
||
]);
|
||
}
|
||
|
||
late final Widget body;
|
||
if (filter.value == InboxFilter.reminders) {
|
||
body = _RemindersList(
|
||
scrollController: scrollController,
|
||
onOpen: openReminder,
|
||
onRefresh: refresh,
|
||
);
|
||
} else if (filter.value == InboxFilter.drafts) {
|
||
body = _DraftsList(
|
||
drafts: drafts,
|
||
scrollController: scrollController,
|
||
channelById: channelById,
|
||
myPubkey: myPk,
|
||
onOpen: openDraft,
|
||
onDelete: (draft) =>
|
||
ref.read(composeDraftsProvider.notifier).remove(draft.key),
|
||
);
|
||
} else if (feedAsync.hasError && allItems.isEmpty) {
|
||
body = _ErrorView(onRetry: refresh);
|
||
} else if (!hasLoadedOnce.value && allItems.isEmpty) {
|
||
body = _LoadingSkeleton(scrollController: scrollController);
|
||
} else if (visibleItems.isEmpty) {
|
||
body = _EmptyFilterState(
|
||
filter: filter.value,
|
||
unreadOnly: unreadOnly.value,
|
||
);
|
||
} else {
|
||
body = BeeRefreshIndicator(
|
||
onRefresh: refresh,
|
||
child: CustomScrollView(
|
||
controller: scrollController,
|
||
slivers: [
|
||
DecoratedSliver(
|
||
decoration: BoxDecoration(
|
||
color: context.colors.surface,
|
||
borderRadius: const BorderRadius.vertical(
|
||
top: Radius.circular(Radii.dialog),
|
||
),
|
||
),
|
||
sliver: SliverPadding(
|
||
padding: _activityScrollPadding(context),
|
||
sliver: SliverList.builder(
|
||
itemCount: visibleItems.length,
|
||
itemBuilder: (context, index) {
|
||
final item = visibleItems[index];
|
||
final channel = item.item.channelId != null
|
||
? channelById[item.item.channelId]
|
||
: null;
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
_InboxRow(
|
||
key: ValueKey(item.id),
|
||
item: item,
|
||
channel: channel,
|
||
currentPubkey: myPk,
|
||
isDone: isDone(item),
|
||
onTap: () => unawaited(openItem(item)),
|
||
onMarkRead: () => markItemRead(item),
|
||
onMarkUnread: () => markItemUnread(item),
|
||
),
|
||
],
|
||
);
|
||
},
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
return IosScrollToTop(
|
||
child: Scaffold(
|
||
backgroundColor: context.colors.surface,
|
||
body: SafeArea(
|
||
bottom: false,
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
Padding(
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: Grid.gutter,
|
||
vertical: Grid.xxs,
|
||
),
|
||
child: Row(
|
||
children: [
|
||
defaultTargetPlatform == TargetPlatform.iOS
|
||
? Padding(
|
||
padding: const EdgeInsets.only(right: Grid.twelve),
|
||
child: IosGlassNavigationButton(
|
||
icon: IosGlassNavigationIcon.close,
|
||
semanticLabel: 'Close activity',
|
||
width: 44,
|
||
height: 44,
|
||
controlSize: 44,
|
||
foregroundColor: navigationPrimaryForeground(
|
||
context,
|
||
),
|
||
onPressed: () => Navigator.of(context).pop(),
|
||
),
|
||
)
|
||
: IconButton(
|
||
tooltip: 'Close activity',
|
||
icon: const Icon(BuzzIcons.x),
|
||
onPressed: () => Navigator.of(context).pop(),
|
||
),
|
||
Expanded(child: Text('Activity', style: headerTitleStyle)),
|
||
],
|
||
),
|
||
),
|
||
SizedBox(
|
||
height: filterHeight,
|
||
child: SingleChildScrollView(
|
||
key: const ValueKey('activity-filters'),
|
||
scrollDirection: Axis.horizontal,
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: Grid.gutter,
|
||
vertical: Grid.half,
|
||
),
|
||
child: Row(
|
||
children: [
|
||
_ActivityMenuButton<InboxFilter>(
|
||
controlKey: 'activity-type-menu',
|
||
popoverKey: 'activity-type-popover',
|
||
semanticLabel:
|
||
'Activity type, ${_activityTypeOptions[filter.value]}',
|
||
value: filter.value,
|
||
active: filter.value != InboxFilter.all,
|
||
options: _activityTypeOptions,
|
||
onChanged: (value) => filter.value = value,
|
||
),
|
||
const SizedBox(width: Grid.xxs),
|
||
_ActivityMenuButton<String>(
|
||
controlKey: 'activity-sender-menu',
|
||
popoverKey: 'activity-sender-popover',
|
||
semanticLabel: 'From ${sender.value}',
|
||
value: sender.value,
|
||
active: sender.value != 'Anyone',
|
||
options: const {
|
||
'Anyone': 'Anyone',
|
||
'People': 'People',
|
||
'Agents': 'Agents',
|
||
},
|
||
onChanged: (value) => sender.value = value,
|
||
),
|
||
const SizedBox(width: Grid.xxs),
|
||
_UnreadFilterButton(
|
||
selected: unreadOnly.value,
|
||
onPressed: () => unreadOnly.value = !unreadOnly.value,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
Expanded(
|
||
child: TopEdgeFade(
|
||
key: const ValueKey('activity-content-viewport'),
|
||
child: body,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|