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