Skip to content
145 changes: 104 additions & 41 deletions mobile/lib/features/activity/activity_page.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import 'dart:async';

import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
Expand Down Expand Up @@ -59,17 +60,52 @@ EdgeInsets _activityScrollPadding(
/// navigation. Row taps deep-link to the represented message (oldest unread
/// for grouped conversations) rather than just opening the channel.
class ActivityPage extends HookConsumerWidget {
const ActivityPage({super.key});
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 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 topSectionHeight = frostedAppBarHeight(
context,
titleStyle: headerTitleStyle,
bottomHeight: Grid.xxs,
);

final readState = ref.watch(readStateProvider);
Expand Down Expand Up @@ -244,12 +280,18 @@ class ActivityPage extends HookConsumerWidget {
]);
}

final Widget body;
late final Widget body;
var bodyRidesOverTopSection = false;
if (filter.value == InboxFilter.reminders) {
body = _RemindersList(onOpen: openReminder, onRefresh: refresh);
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,
Expand All @@ -259,7 +301,7 @@ class ActivityPage extends HookConsumerWidget {
} else if (feedAsync.hasError && allItems.isEmpty) {
body = _ErrorView(onRetry: refresh);
} else if (!hasLoadedOnce.value && allItems.isEmpty) {
body = const _LoadingSkeleton();
body = _LoadingSkeleton(scrollController: scrollController);
} else if (visibleItems.isEmpty) {
body = _EmptyFilterState(
filter: filter.value,
Expand All @@ -274,54 +316,73 @@ class ActivityPage extends HookConsumerWidget {
? firstReadIndex
: -1;

bodyRidesOverTopSection = true;
body = RefreshIndicator(
edgeOffset: topSectionHeight,
onRefresh: refresh,
child: ListView.builder(
padding: _activityScrollPadding(context),
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: [
if (index == newBoundaryIndex) const _NewBoundaryDivider(),
_InboxRow(
key: ValueKey(item.id),
item: item,
channel: channel,
currentPubkey: myPk,
isDone: isDone(item),
onTap: () => openItem(item),
onMarkRead: () => markItemRead(item),
onMarkUnread: () => markItemUnread(item),
child: CustomScrollView(
controller: scrollController,
slivers: [
SliverToBoxAdapter(child: SizedBox(height: topSectionHeight)),
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: [
if (index == newBoundaryIndex)
const _NewBoundaryDivider(),
_InboxRow(
key: ValueKey(item.id),
item: item,
channel: channel,
currentPubkey: myPk,
isDone: isDone(item),
onTap: () => openItem(item),
onMarkRead: () => markItemRead(item),
onMarkUnread: () => markItemUnread(item),
),
],
);
},
),
],
);
},
),
),
],
),
);
}

return FrostedScaffold(
backgroundColor: Colors.transparent,
backgroundColor: context.colors.surface,
appBar: FrostedAppBar(
gradient: context.appColors.topSectionGradient,
automaticallyImplyLeading: false,
title: const Text('Activity'),
horizontalInset: Grid.gutter,
showBottomDivider: true,
bottomDividerOpacity: 0.06,
title: Text('Activity', style: headerTitleStyle),
Comment thread
klopez4212 marked this conversation as resolved.
titleStyle: headerTitleStyle,
actions: [
_FilterMenuButton(
_ActivityActionsPill(
filter: filter.value,
dueReminderCount: dueReminderCount,
draftCount: drafts.length,
onChanged: (f) => filter.value = f,
),
_InboxOptionsButton(
unreadOnly: unreadOnly.value,
unreadCount: unreadVisibleCount,
onFilterChanged: (f) => filter.value = f,
onUnreadOnlyChanged: (v) => unreadOnly.value = v,
onMarkAllRead: () {
for (final item in visibleItems) {
Expand All @@ -330,17 +391,19 @@ class ActivityPage extends HookConsumerWidget {
},
),
],
bottomHeight: Grid.xxs,
bottom: const SizedBox.expand(),
),
body: SafeArea(
key: const ValueKey('activity-content-safe-area'),
top: false,
bottom: false,
child: Padding(
padding: EdgeInsets.only(
top: frostedAppBarHeight(context, titleStyle: headerTitleStyle),
),
child: body,
),
child: bodyRidesOverTopSection
? body
: Padding(
padding: EdgeInsets.only(top: topSectionHeight),
child: body,
),
),
);
}
Expand Down
64 changes: 62 additions & 2 deletions mobile/lib/features/activity/activity_page/header_actions.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,59 @@ const _filterLabels = <InboxFilter, String>{
InboxFilter.drafts: 'Drafts',
};

class _ActivityActionsPill extends StatelessWidget {
final InboxFilter filter;
final int dueReminderCount;
final int draftCount;
final bool unreadOnly;
final int unreadCount;
final ValueChanged<InboxFilter> onFilterChanged;
final ValueChanged<bool> onUnreadOnlyChanged;
final VoidCallback onMarkAllRead;

const _ActivityActionsPill({
required this.filter,
required this.dueReminderCount,
required this.draftCount,
required this.unreadOnly,
required this.unreadCount,
required this.onFilterChanged,
required this.onUnreadOnlyChanged,
required this.onMarkAllRead,
});

@override
Widget build(BuildContext context) => ClipRRect(
borderRadius: BorderRadius.circular(Radii.full),
child: DecoratedBox(
decoration: BoxDecoration(
color: context.colors.primaryContainer,
borderRadius: BorderRadius.circular(Radii.full),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: Grid.quarter),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_FilterMenuButton(
filter: filter,
dueReminderCount: dueReminderCount,
draftCount: draftCount,
onChanged: onFilterChanged,
),
_InboxOptionsButton(
unreadOnly: unreadOnly,
unreadCount: unreadCount,
onUnreadOnlyChanged: onUnreadOnlyChanged,
onMarkAllRead: onMarkAllRead,
),
],
),
),
),
);
}

/// Compact filter dropdown replacing the old chip rail — mirrors desktop's
/// inbox filter menu (`FILTER_OPTIONS`).
class _FilterMenuButton extends StatelessWidget {
Expand Down Expand Up @@ -91,14 +144,15 @@ class _FilterMenuButton extends StatelessWidget {
Text(
_filterLabels[filter]!,
style: context.textTheme.labelLarge?.copyWith(
color: navigationPrimaryForeground(context),
fontWeight: FontWeight.w600,
),
),
const SizedBox(width: Grid.quarter),
Icon(
LucideIcons.chevronDown,
size: 16,
color: context.colors.onSurfaceVariant,
color: navigationPrimaryForeground(context),
),
if (dueReminderCount > 0 || draftCount > 0) ...[
const SizedBox(width: Grid.quarter),
Expand Down Expand Up @@ -133,7 +187,7 @@ class _CountBadge extends StatelessWidget {
vertical: Grid.quarter,
),
decoration: BoxDecoration(
color: context.colors.primary,
color: navigationPrimaryForeground(context),
borderRadius: BorderRadius.circular(Grid.xxs),
),
child: Text(
Expand Down Expand Up @@ -168,6 +222,12 @@ class _InboxOptionsButton extends StatelessWidget {
builder: (buttonContext) => IconButton(
key: const ValueKey('activity-options-menu'),
tooltip: 'Activity options',
color: navigationPrimaryForeground(context),
padding: const EdgeInsets.symmetric(horizontal: Grid.xxs),
constraints: const BoxConstraints.tightFor(
width: Grid.xl,
height: Grid.xl,
),
icon: const Icon(LucideIcons.ellipsis, size: 20),
onPressed: () async {
final selected = await showAnchoredPopover<String>(
Expand Down
11 changes: 10 additions & 1 deletion mobile/lib/features/activity/activity_page/lists.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,15 @@ part of '../activity_page.dart';
/// Reminders surface for the Reminders filter — due/pending NIP-ER
/// reminders that deep-link to their target message.
class _RemindersList extends ConsumerWidget {
final ScrollController scrollController;
final void Function(Reminder reminder) onOpen;
final Future<void> Function() onRefresh;

const _RemindersList({required this.onOpen, required this.onRefresh});
const _RemindersList({
required this.scrollController,
required this.onOpen,
required this.onRefresh,
});

@override
Widget build(BuildContext context, WidgetRef ref) {
Expand Down Expand Up @@ -36,6 +41,7 @@ class _RemindersList extends ConsumerWidget {
return RefreshIndicator(
onRefresh: onRefresh,
child: ListView.builder(
controller: scrollController,
padding: _activityScrollPadding(context),
itemCount: reminders.length,
itemBuilder: (context, index) {
Expand Down Expand Up @@ -73,13 +79,15 @@ class _RemindersList extends ConsumerWidget {
/// text that reopens the target composer.
class _DraftsList extends StatelessWidget {
final List<ComposeDraft> drafts;
final ScrollController scrollController;
final Map<String, Channel> channelById;
final String? myPubkey;
final void Function(ComposeDraft draft) onOpen;
final void Function(ComposeDraft draft) onDelete;

const _DraftsList({
required this.drafts,
required this.scrollController,
required this.channelById,
required this.myPubkey,
required this.onOpen,
Expand All @@ -97,6 +105,7 @@ class _DraftsList extends StatelessWidget {
}

return ListView.builder(
controller: scrollController,
padding: _activityScrollPadding(context),
itemCount: drafts.length,
itemBuilder: (context, index) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
part of '../activity_page.dart';

class _LoadingSkeleton extends StatelessWidget {
const _LoadingSkeleton();
final ScrollController scrollController;

const _LoadingSkeleton({required this.scrollController});

@override
Widget build(BuildContext context) {
return ListView.separated(
controller: scrollController,
padding: _activityScrollPadding(
context,
horizontal: Grid.gutter,
Expand Down
Loading
Loading