|  | 
|  | 1 | + | 
|  | 2 | +import 'package:collection/collection.dart'; | 
|  | 3 | +import 'package:flutter/material.dart'; | 
|  | 4 | + | 
|  | 5 | +import '../api/model/model.dart'; | 
|  | 6 | +import '../model/narrow.dart'; | 
|  | 7 | +import '../model/unreads.dart'; | 
|  | 8 | +import 'icons.dart'; | 
|  | 9 | +import 'message_list.dart'; | 
|  | 10 | +import 'page.dart'; | 
|  | 11 | +import 'store.dart'; | 
|  | 12 | +import 'text.dart'; | 
|  | 13 | +import 'unread_count_badge.dart'; | 
|  | 14 | + | 
|  | 15 | +/// Scrollable listing of subscribed streams. | 
|  | 16 | +class SubscriptionListPage extends StatefulWidget { | 
|  | 17 | +  const SubscriptionListPage({super.key}); | 
|  | 18 | + | 
|  | 19 | +  static Route<void> buildRoute({required BuildContext context}) { | 
|  | 20 | +    return MaterialAccountWidgetRoute(context: context, | 
|  | 21 | +      page: const SubscriptionListPage()); | 
|  | 22 | +  } | 
|  | 23 | + | 
|  | 24 | +  @override | 
|  | 25 | +  State<SubscriptionListPage> createState() => _SubscriptionListPageState(); | 
|  | 26 | +} | 
|  | 27 | + | 
|  | 28 | +class _SubscriptionListPageState extends State<SubscriptionListPage> with PerAccountStoreAwareStateMixin<SubscriptionListPage> { | 
|  | 29 | +  Map<int, Subscription>? subscriptions; | 
|  | 30 | +  Unreads? unreadsModel; | 
|  | 31 | + | 
|  | 32 | +  @override | 
|  | 33 | +  void onNewStore() { | 
|  | 34 | +    final store = PerAccountStoreWidget.of(context); | 
|  | 35 | +    subscriptions = store.subscriptions; | 
|  | 36 | + | 
|  | 37 | +    unreadsModel?.removeListener(_modelChanged); | 
|  | 38 | +    unreadsModel = store.unreads | 
|  | 39 | +      ..addListener(_modelChanged); | 
|  | 40 | +  } | 
|  | 41 | + | 
|  | 42 | +  @override | 
|  | 43 | +  void dispose() { | 
|  | 44 | +    unreadsModel?.removeListener(_modelChanged); | 
|  | 45 | +    super.dispose(); | 
|  | 46 | +  } | 
|  | 47 | + | 
|  | 48 | +  void _modelChanged() { | 
|  | 49 | +    setState(() { | 
|  | 50 | +      // The actual state lives in [subscriptions] and [unreadsModel]. | 
|  | 51 | +      // This method was called because one of those just changed. | 
|  | 52 | +    }); | 
|  | 53 | +  } | 
|  | 54 | + | 
|  | 55 | +  @override | 
|  | 56 | +  Widget build(BuildContext context) { | 
|  | 57 | +    // Design referenced from: | 
|  | 58 | +    //   https://www.figma.com/file/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?type=design&node-id=171-12359&mode=design&t=4d0vykoYQ0KGpFuu-0 | 
|  | 59 | + | 
|  | 60 | +    // This is an initial version with "Pinned" and "Unpinned" | 
|  | 61 | +    // sections following behavior in mobile. Recalculating | 
|  | 62 | +    // groups and sorting on every `build` here: it performs well | 
|  | 63 | +    // enough and not worth optimizing as it will be replaced | 
|  | 64 | +    // with a different behavior: | 
|  | 65 | +    // TODO: Implement new grouping behavior and design, see discussion at: | 
|  | 66 | +    //   https://chat.zulip.org/#narrow/stream/101-design/topic/UI.20redesign.3A.20left.20sidebar/near/1540147 | 
|  | 67 | + | 
|  | 68 | +    // TODO: Implement collapsible topics | 
|  | 69 | + | 
|  | 70 | +    // TODO(i18n): localize strings on page | 
|  | 71 | +    //   Strings here left unlocalized as they likely will not | 
|  | 72 | +    //   exist in the settled design. | 
|  | 73 | +    final List<Subscription> pinned = []; | 
|  | 74 | +    final List<Subscription> unpinned = []; | 
|  | 75 | +    for (final subscription in subscriptions!.values) { | 
|  | 76 | +      if (subscription.pinToTop) { | 
|  | 77 | +        pinned.add(subscription); | 
|  | 78 | +      } else { | 
|  | 79 | +        unpinned.add(subscription); | 
|  | 80 | +      } | 
|  | 81 | +    } | 
|  | 82 | +    // TODO(i18n): add locale-aware sorting | 
|  | 83 | +    pinned.sortBy((subscription) => subscription.name); | 
|  | 84 | +    unpinned.sortBy((subscription) => subscription.name); | 
|  | 85 | + | 
|  | 86 | +    return Scaffold( | 
|  | 87 | +      appBar: AppBar(title: const Text("Streams")), | 
|  | 88 | +      body: Builder( | 
|  | 89 | +        builder: (BuildContext context) => Center( | 
|  | 90 | +          child: CustomScrollView( | 
|  | 91 | +            slivers: [ | 
|  | 92 | +              if (pinned.isEmpty && unpinned.isEmpty) | 
|  | 93 | +                const _NoSubscriptionsItem(), | 
|  | 94 | +              if (pinned.isNotEmpty) ...[ | 
|  | 95 | +                _SubscriptionListHeader(context: context, label: "Pinned"), | 
|  | 96 | +                _SubscriptionList(unreadsModel: unreadsModel, subscriptions: pinned), | 
|  | 97 | +              ], | 
|  | 98 | +              if (unpinned.isNotEmpty) ...[ | 
|  | 99 | +                _SubscriptionListHeader(context: context, label: "Unpinned"), | 
|  | 100 | +                _SubscriptionList(unreadsModel: unreadsModel, subscriptions: unpinned), | 
|  | 101 | +              ], | 
|  | 102 | + | 
|  | 103 | +              // TODO(#188): add button to "All Streams" page with ability to subscribe | 
|  | 104 | + | 
|  | 105 | +              // This ensures last item in scrollable can settle in an unobstructed area. | 
|  | 106 | +              const SliverSafeArea(sliver: SliverToBoxAdapter(child: SizedBox.shrink())), | 
|  | 107 | +            ])))); | 
|  | 108 | +  } | 
|  | 109 | +} | 
|  | 110 | + | 
|  | 111 | +class _NoSubscriptionsItem extends StatelessWidget { | 
|  | 112 | +  const _NoSubscriptionsItem(); | 
|  | 113 | + | 
|  | 114 | +  @override | 
|  | 115 | +  Widget build(BuildContext context) { | 
|  | 116 | +    return SliverToBoxAdapter( | 
|  | 117 | +      child: Padding( | 
|  | 118 | +        padding: const EdgeInsets.all(10), | 
|  | 119 | +        child: Text("No streams found", | 
|  | 120 | +          textAlign: TextAlign.center, | 
|  | 121 | +          style: TextStyle( | 
|  | 122 | +          color: const HSLColor.fromAHSL(1.0, 240, 0.1, 0.5).toColor(), | 
|  | 123 | +          fontFamily: 'Source Sans 3', | 
|  | 124 | +          fontSize: 18, | 
|  | 125 | +          height: (20 / 18), | 
|  | 126 | +        ).merge(weightVariableTextStyle(context))))); | 
|  | 127 | +  } | 
|  | 128 | +} | 
|  | 129 | + | 
|  | 130 | +class _SubscriptionListHeader extends StatelessWidget { | 
|  | 131 | +  const _SubscriptionListHeader({ | 
|  | 132 | +    required this.context, | 
|  | 133 | +    required this.label, | 
|  | 134 | +  }); | 
|  | 135 | + | 
|  | 136 | +  final BuildContext context; | 
|  | 137 | +  final String label; | 
|  | 138 | + | 
|  | 139 | +  @override | 
|  | 140 | +  Widget build(BuildContext context) { | 
|  | 141 | +    return SliverToBoxAdapter( | 
|  | 142 | +      child: ColoredBox( | 
|  | 143 | +        color: Colors.white, | 
|  | 144 | +        child: SizedBox( | 
|  | 145 | +          height: 30, | 
|  | 146 | +          child: Row( | 
|  | 147 | +            children: [ | 
|  | 148 | +              const SizedBox(width: 16), | 
|  | 149 | +              Expanded(child: Divider( | 
|  | 150 | +                color: const HSLColor.fromAHSL(0.2, 240, 0.1, 0.5).toColor())), | 
|  | 151 | +              const SizedBox(width: 8), | 
|  | 152 | +              Text(label, | 
|  | 153 | +                textAlign: TextAlign.center, | 
|  | 154 | +                style: TextStyle( | 
|  | 155 | +                  color: const HSLColor.fromAHSL(1.0, 240, 0.1, 0.5).toColor(), | 
|  | 156 | +                  fontFamily: 'Source Sans 3', | 
|  | 157 | +                  fontSize: 14, | 
|  | 158 | +                  letterSpacing: 0.56, | 
|  | 159 | +                  height: (16 / 14), | 
|  | 160 | +                ).merge(weightVariableTextStyle(context))), | 
|  | 161 | +              const SizedBox(width: 8), | 
|  | 162 | +              Expanded(child: Divider( | 
|  | 163 | +                color: const HSLColor.fromAHSL(0.2, 240, 0.1, 0.5).toColor())), | 
|  | 164 | +              const SizedBox(width: 16), | 
|  | 165 | +            ])))); | 
|  | 166 | +  } | 
|  | 167 | +} | 
|  | 168 | + | 
|  | 169 | +class _SubscriptionList extends StatelessWidget { | 
|  | 170 | +  const _SubscriptionList({ | 
|  | 171 | +    required this.unreadsModel, | 
|  | 172 | +    required this.subscriptions, | 
|  | 173 | +  }); | 
|  | 174 | + | 
|  | 175 | +  final Unreads? unreadsModel; | 
|  | 176 | +  final List<Subscription> subscriptions; | 
|  | 177 | + | 
|  | 178 | +  @override | 
|  | 179 | +  Widget build(BuildContext context) { | 
|  | 180 | +    return SliverList.builder( | 
|  | 181 | +      itemCount: subscriptions.length, | 
|  | 182 | +      itemBuilder: (BuildContext context, int index) { | 
|  | 183 | +        final subscription = subscriptions[index]; | 
|  | 184 | +        final unreadCount = unreadsModel!.countInStreamNarrow(subscription.streamId); | 
|  | 185 | +        return SubscriptionItem(subscription: subscription, unreadCount: unreadCount); | 
|  | 186 | +    }); | 
|  | 187 | +  } | 
|  | 188 | +} | 
|  | 189 | + | 
|  | 190 | +@visibleForTesting | 
|  | 191 | +class SubscriptionItem extends StatelessWidget { | 
|  | 192 | +  const SubscriptionItem({ | 
|  | 193 | +    super.key, | 
|  | 194 | +    required this.subscription, | 
|  | 195 | +    required this.unreadCount, | 
|  | 196 | +  }); | 
|  | 197 | + | 
|  | 198 | +  final Subscription subscription; | 
|  | 199 | +  final int unreadCount; | 
|  | 200 | + | 
|  | 201 | +  @override | 
|  | 202 | +  Widget build(BuildContext context) { | 
|  | 203 | +    final swatch = subscription.colorSwatch(); | 
|  | 204 | +    final hasUnreads = (unreadCount > 0); | 
|  | 205 | +    return Material( | 
|  | 206 | +      color: Colors.white, | 
|  | 207 | +      child: InkWell( | 
|  | 208 | +        onTap: () { | 
|  | 209 | +          Navigator.push(context, | 
|  | 210 | +            MessageListPage.buildRoute(context: context, narrow: StreamNarrow(subscription.streamId))); | 
|  | 211 | +        }, | 
|  | 212 | +        child: SizedBox(height: 40, | 
|  | 213 | +          child: Row(crossAxisAlignment: CrossAxisAlignment.center, children: [ | 
|  | 214 | +            const SizedBox(width: 16), | 
|  | 215 | +            Icon(size: 18, color: swatch.iconOnPlainBackground, | 
|  | 216 | +              iconDataFromStream(subscription)), | 
|  | 217 | +            const SizedBox(width: 5), | 
|  | 218 | +            Expanded( | 
|  | 219 | +              child: Text( | 
|  | 220 | +                style: const TextStyle( | 
|  | 221 | +                  fontFamily: 'Source Sans 3', | 
|  | 222 | +                  fontSize: 18, | 
|  | 223 | +                  height: (20 / 18), | 
|  | 224 | +                  color: Color(0xFF262626), | 
|  | 225 | +                ).merge(hasUnreads | 
|  | 226 | +                  ? weightVariableTextStyle(context, wght: 600, wghtIfPlatformRequestsBold: 900) | 
|  | 227 | +                  : weightVariableTextStyle(context)), | 
|  | 228 | +                maxLines: 1, | 
|  | 229 | +                overflow: TextOverflow.ellipsis, | 
|  | 230 | +                subscription.name)), | 
|  | 231 | +            if (unreadCount > 0) ...[ | 
|  | 232 | +              const SizedBox(width: 12), | 
|  | 233 | +              // TODO(#384) show @-mention indicator when it applies | 
|  | 234 | +              UnreadCountBadge(count: unreadCount, backgroundColor: swatch, bold: true), | 
|  | 235 | +            ], | 
|  | 236 | +            const SizedBox(width: 16), | 
|  | 237 | +          ])))); | 
|  | 238 | +  } | 
|  | 239 | +} | 
0 commit comments