1
+ import 'package:flutter/material.dart' ;
2
+
3
+ import '../api/model/model.dart' ;
4
+ import '../api/route/channels.dart' ;
5
+ import '../generated/l10n/zulip_localizations.dart' ;
6
+ import '../model/message_list.dart' ;
7
+ import '../model/narrow.dart' ;
8
+ import 'app_bar.dart' ;
9
+ import 'icons.dart' ;
10
+ import 'message_list.dart' ;
11
+ import 'page.dart' ;
12
+ import 'store.dart' ;
13
+ import 'theme.dart' ;
14
+ import 'unread_count_badge.dart' ;
15
+
16
+ /// A page that displays a list of topics in a channel.
17
+ class TopicListPage extends StatefulWidget {
18
+ const TopicListPage ({
19
+ super .key,
20
+ required this .streamId,
21
+ required this .messageListView,
22
+ });
23
+
24
+ final int streamId;
25
+ final MessageListView messageListView;
26
+ static AccountRoute <void > buildRoute ({
27
+ int ? accountId,
28
+ BuildContext ? context,
29
+ required int streamId,
30
+ required MessageListView messageListView,
31
+ }) {
32
+ return MaterialAccountWidgetRoute (
33
+ accountId: accountId,
34
+ context: context,
35
+ page: TopicListPage (streamId: streamId, messageListView: messageListView),
36
+ );
37
+ }
38
+
39
+ @override
40
+ State <TopicListPage > createState () => _TopicListPageState ();
41
+ }
42
+
43
+ class _TopicListPageState extends State <TopicListPage > with PerAccountStoreAwareStateMixin <TopicListPage > {
44
+ bool _isLoading = true ;
45
+ List <GetStreamTopicsEntry > _topics = [];
46
+ List <GetStreamTopicsEntry > _filteredTopics = [];
47
+ MessageListView ? _model;
48
+
49
+ late TextEditingController _searchController;
50
+ bool _isSearching = false ;
51
+
52
+ @override
53
+ void initState () {
54
+ super .initState ();
55
+ _searchController = TextEditingController ();
56
+ _searchController.addListener (_filterTopics);
57
+ }
58
+
59
+ @override
60
+ void onNewStore () {
61
+ _model = widget.messageListView;
62
+ _model! .addListener (_onMessageListChanged);
63
+ _fetchTopics ();
64
+ }
65
+
66
+ void _onMessageListChanged () {
67
+ _fetchTopics ();
68
+ }
69
+
70
+ Future <void > _fetchTopics () async {
71
+ final store = PerAccountStoreWidget .of (context);
72
+ final result = await getStreamTopics (
73
+ store.connection,
74
+ streamId: widget.streamId,
75
+ );
76
+
77
+ setState (() {
78
+ _topics = result.topics;
79
+ _filterTopics (); // This will update _filteredTopics
80
+ _isLoading = false ;
81
+ });
82
+ }
83
+
84
+ void _filterTopics () {
85
+ setState (() {
86
+ final query = _searchController.text.trim ().toLowerCase ();
87
+ if (query.isEmpty) {
88
+ _filteredTopics = List .from (_topics);
89
+ } else {
90
+ _filteredTopics = _topics
91
+ .where ((topic) => topic.name.displayName.toLowerCase ().contains (query))
92
+ .toList ();
93
+ }
94
+
95
+ // Sort filtered topics by recency (most recent first)
96
+ _filteredTopics.sort ((a, b) => b.maxId.compareTo (a.maxId));
97
+ });
98
+ }
99
+
100
+ void _toggleSearch () {
101
+ setState (() {
102
+ _isSearching = ! _isSearching;
103
+ if (! _isSearching) {
104
+ _searchController.clear ();
105
+ }
106
+ });
107
+ }
108
+
109
+ @override
110
+ void dispose () {
111
+ _searchController.dispose ();
112
+ _model? .removeListener (_onMessageListChanged);
113
+ super .dispose ();
114
+ }
115
+
116
+ @override
117
+ Widget build (BuildContext context) {
118
+ final store = PerAccountStoreWidget .of (context);
119
+ final stream = store.streams[widget.streamId];
120
+ final designVariables = DesignVariables .of (context);
121
+ final zulipLocalizations = ZulipLocalizations .of (context);
122
+
123
+ return Scaffold (
124
+ appBar: _isSearching
125
+ ? AppBar (
126
+ backgroundColor: designVariables.background,
127
+ title: TextField (
128
+ controller: _searchController,
129
+ autofocus: true ,
130
+ decoration: InputDecoration (
131
+ hintText: 'Search topic' ,
132
+ border: InputBorder .none,
133
+ ),
134
+ style: const TextStyle (fontSize: 16 ),
135
+ ),
136
+ leading: IconButton (
137
+ icon: const Icon (Icons .arrow_back),
138
+ onPressed: _toggleSearch,
139
+ ),
140
+ actions: [
141
+ if (_searchController.text.isNotEmpty)
142
+ IconButton (
143
+ icon: const Icon (Icons .clear),
144
+ onPressed: () => _searchController.clear (),
145
+ ),
146
+ ],
147
+ )
148
+ : ZulipAppBar (
149
+ title: Text (stream? .name ?? 'Topics' ),
150
+ backgroundColor: designVariables.background,
151
+ actions: [
152
+ IconButton (
153
+ icon: const Icon (Icons .search),
154
+ onPressed: _toggleSearch,
155
+ tooltip: zulipLocalizations.searchTopicsPlaceholder,
156
+ ),
157
+ ],
158
+ ),
159
+ body: _buildBody (context),
160
+ );
161
+ }
162
+
163
+ Widget _buildBody (BuildContext context) {
164
+ if (_isLoading) {
165
+ return const Center (child: CircularProgressIndicator ());
166
+ }
167
+
168
+ if (_topics.isEmpty) {
169
+ return const Center (
170
+ child: Text ('No topics in this channel' ),
171
+ );
172
+ }
173
+
174
+ if (_filteredTopics.isEmpty && _searchController.text.isNotEmpty) {
175
+ return Center (
176
+ child: Text ('No topics matching "${_searchController .text }"' ),
177
+ );
178
+ }
179
+
180
+ return ListView .builder (
181
+ itemCount: _filteredTopics.length,
182
+ itemBuilder: (context, index) {
183
+ final topic = _filteredTopics[index];
184
+ return _TopicItem (
185
+ streamId: widget.streamId,
186
+ topic: topic.name,
187
+ );
188
+ },
189
+ );
190
+ }
191
+ }
192
+
193
+ class _TopicItem extends StatelessWidget {
194
+ const _TopicItem ({
195
+ required this .streamId,
196
+ required this .topic,
197
+ });
198
+
199
+ final int streamId;
200
+ final TopicName topic;
201
+
202
+ @override
203
+ Widget build (BuildContext context) {
204
+ final store = PerAccountStoreWidget .of (context);
205
+ final unreads = store.unreads;
206
+ final unreadCount = unreads.countInTopicNarrow (streamId, topic);
207
+ final hasMentions = unreads.mentions.any ((id) {
208
+ final message = store.messages[id];
209
+ return message is StreamMessage &&
210
+ message.streamId == streamId &&
211
+ message.topic == topic;
212
+ });
213
+ final isMuted = ! store.isTopicVisibleInStream (streamId, topic);
214
+
215
+ final designVariables = DesignVariables .of (context);
216
+ final opacity = isMuted ? 0.55 : 1.0 ;
217
+
218
+ return Material (
219
+ color: designVariables.background,
220
+ child: InkWell (
221
+ onTap: () {
222
+ Navigator .push (context,
223
+ MessageListPage .buildRoute (context: context,
224
+ narrow: TopicNarrow (streamId, topic)));
225
+ },
226
+ child: Padding (
227
+ padding: const EdgeInsets .symmetric (horizontal: 16.0 , vertical: 12.0 ),
228
+ child: Row (
229
+ children: [
230
+ Opacity (
231
+ opacity: opacity,
232
+ child: Icon (
233
+ ZulipIcons .topic,
234
+ size: 18 ,
235
+ color: designVariables.icon,
236
+ ),
237
+ ),
238
+ const SizedBox (width: 8 ),
239
+ Expanded (
240
+ child: Opacity (
241
+ opacity: opacity,
242
+ child: Text (
243
+ topic.displayName,
244
+ style: TextStyle (
245
+ fontWeight: unreadCount > 0 ? FontWeight .bold : FontWeight .normal,
246
+ ),
247
+ overflow: TextOverflow .ellipsis,
248
+ ),
249
+ ),
250
+ ),
251
+ if (isMuted)
252
+ Padding (
253
+ padding: const EdgeInsets .only (left: 8.0 ),
254
+ child: Icon (
255
+ ZulipIcons .mute,
256
+ size: 16 ,
257
+ color: designVariables.icon.withValues (alpha: 0.5 ),
258
+ ),
259
+ ),
260
+ if (unreadCount > 0 )
261
+ Padding (
262
+ padding: const EdgeInsets .only (left: 8.0 ),
263
+ child: UnreadCountBadge (
264
+ count: unreadCount,
265
+ backgroundColor: null ,
266
+ bold: hasMentions,
267
+ ),
268
+ ),
269
+ ],
270
+ ),
271
+ ),
272
+ ),
273
+ );
274
+ }
275
+ }
0 commit comments