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