diff --git a/lib/widgets/app.dart b/lib/widgets/app.dart index 771fd4f53c..35d291ffef 100644 --- a/lib/widgets/app.dart +++ b/lib/widgets/app.dart @@ -13,6 +13,7 @@ import 'message_list.dart'; import 'page.dart'; import 'recent_dm_conversations.dart'; import 'store.dart'; +import 'stream_list.dart'; class ZulipApp extends StatelessWidget { const ZulipApp({super.key, this.navigatorObservers}); @@ -249,6 +250,11 @@ class HomePage extends StatelessWidget { narrow: const AllMessagesNarrow())), child: const Text("All messages")), const SizedBox(height: 16), + ElevatedButton( + onPressed: () => Navigator.push(context, + StreamListPage.buildRoute(context: context)), + child: const Text("Subscribed streams")), + const SizedBox(height: 16), ElevatedButton( onPressed: () => Navigator.push(context, RecentDmConversationsPage.buildRoute(context: context)), diff --git a/lib/widgets/stream_list.dart b/lib/widgets/stream_list.dart new file mode 100644 index 0000000000..2da7a80d38 --- /dev/null +++ b/lib/widgets/stream_list.dart @@ -0,0 +1,229 @@ +import 'dart:ui'; + +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; + +import '../api/model/model.dart'; +import '../model/narrow.dart'; +import '../model/unreads.dart'; +import 'icons.dart'; +import 'message_list.dart'; +import 'page.dart'; +import 'store.dart'; +import 'text.dart'; + +/// Scrollable listing of subscribed streams. +class StreamListPage extends StatefulWidget { + const StreamListPage({super.key}); + + static Route buildRoute({required BuildContext context}) { + return MaterialAccountWidgetRoute(context: context, + page: const StreamListPage()); + } + + @override + State createState() => _StreamListPageState(); +} + +class _StreamListPageState extends State with PerAccountStoreAwareStateMixin { + Map? subscriptions; + Unreads? unreadsModel; + + @override + void onNewStore() { + final store = PerAccountStoreWidget.of(context); + subscriptions = store.subscriptions; + + unreadsModel?.removeListener(_modelChanged); + unreadsModel = store.unreads + ..addListener(_modelChanged); + } + + @override + void dispose() { + unreadsModel?.removeListener(_modelChanged); + super.dispose(); + } + + void _modelChanged() { + setState(() { + // The actual state lives in [subscriptions] and [unreadsModel]. + // This method was called because one of those just changed. + }); + } + + @override + Widget build(BuildContext context) { + final List pinned = []; + final List active = []; + for (final subscription in subscriptions!.values) { + switch (subscription) { + case Subscription(pinToTop: true): + pinned.add(subscription); + default: + active.add(subscription); + } + } + // TODO(intl): add locale-aware sorting + pinned.sortBy((subscription) => subscriptions![subscription.streamId]?.name ?? ''); + active.sortBy((subscription) => subscriptions![subscription.streamId]?.name ?? ''); + return Scaffold( + appBar: AppBar(title: const Text("Streams")), + body: Builder( + builder: (BuildContext context) => Center( + child: CustomScrollView( + slivers: [ + if (pinned.isNotEmpty) ...[ + _SubscriptionListHeader(context: context, label: "Pinned"), + _SubscriptionList(unreadsModel: unreadsModel, subscriptions: pinned, padBottom: active.isEmpty), + ], + if (active.isNotEmpty) ...[ + _SubscriptionListHeader(context: context, label: "Active"), + _SubscriptionList(unreadsModel: unreadsModel, subscriptions: active, padBottom: true), + ], + ])))); + } +} + +class _SubscriptionListHeader extends StatelessWidget { + const _SubscriptionListHeader({ + required this.context, + required this.label, + }); + + final BuildContext context; + final String label; + + @override + Widget build(BuildContext context) { + return SliverToBoxAdapter( + child: SizedBox( + height: 30, + child: Row( + children: [ + const SizedBox(width: 16), + Expanded(child: Divider( + color: const HSLColor.fromAHSL(0.2, 240, 0.1, 0.5).toColor())), + const SizedBox(width: 8), + Text(label, + textAlign: TextAlign.center, + style: TextStyle( + color: const HSLColor.fromAHSL(1.0, 240, 0.1, 0.5).toColor(), + fontFamily: 'Source Sans 3', + fontSize: 14, + letterSpacing: 1.04, + height: (16 / 14), + ).merge(weightVariableTextStyle(context))), + const SizedBox(width: 8), + Expanded(child: Divider( + color: const HSLColor.fromAHSL(0.2, 240, 0.1, 0.5).toColor())), + const SizedBox(width: 16), + ]))); + } +} + +class _SubscriptionList extends StatelessWidget { + const _SubscriptionList({ + required this.unreadsModel, + required this.subscriptions, + required this.padBottom, + }); + + final Unreads? unreadsModel; + final List subscriptions; + final bool padBottom; + + @override + Widget build(BuildContext context) { + return SliverSafeArea( + bottom: padBottom, + sliver: SliverList.builder( + itemCount: subscriptions.length, + itemBuilder: (BuildContext context, int index) { + final subscription = subscriptions[index]; + final unreadCount = unreadsModel!.countInStreamNarrow(subscription.streamId); + return StreamListItem(subscription: subscription, unreadCount: unreadCount); + })); + } +} + +class StreamListItem extends StatelessWidget { + const StreamListItem({ + super.key, + required this.subscription, + required this.unreadCount, + }); + + final Subscription subscription; + final int unreadCount; + + @override + Widget build(BuildContext context) { + final streamColor = colorForStream(subscription); + return InkWell( + onTap: () { + Navigator.push(context, + MessageListPage.buildRoute(context: context, narrow: StreamNarrow(subscription.streamId))); + }, + child: ConstrainedBox(constraints: const BoxConstraints(minHeight: 40), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + _StreamListIcon(subscription: subscription, color: streamColor), + const SizedBox(width: 5), + Expanded(child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Text(subscription.name, + style: const TextStyle( + fontFamily: 'Source Sans 3', + fontSize: 18, + color: Color(0xFF262626), + fontWeight: FontWeight.w600, + ).merge(weightVariableTextStyle(context, + wght: (unreadCount > 0) ? 600 : 400, + wghtIfPlatformRequestsBold: 600)), + overflow: TextOverflow.ellipsis))), + const SizedBox(width: 12), + unreadCount > 0 + ? DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(3), + color: const Color.fromRGBO(102, 102, 153, 0.15), + ), + child: Padding( + padding: const EdgeInsetsDirectional.fromSTEB(4, 0, 4, 1), + child: Text(unreadCount.toString(), + style: const TextStyle( + fontFamily: 'Source Sans 3', + fontSize: 16, + height: (18 / 16), + fontFeatures: [FontFeature.enable('smcp')], // small caps + color: Color(0xFF222222), + ).merge(weightVariableTextStyle(context))))) + : const SizedBox(), + ], + ) + ))); + } +} + +class _StreamListIcon extends StatelessWidget { + const _StreamListIcon({ + required this.subscription, + required this.color, + }); + + final Subscription subscription; + final Color color; + + @override + Widget build(BuildContext context) { + final icon = switch (subscription) { + Subscription(isWebPublic: true) => ZulipIcons.globe, + Subscription(inviteOnly: true) => ZulipIcons.lock, + Subscription() => ZulipIcons.hash_sign, + }; + return Icon(icon, size: 18, color: color); + } +}