Skip to content

Commit c3e4e36

Browse files
msglist: Add topic list page
This commit creates a new topic list page showing all topics in current channel with search functionality ordered by recency Also adds a button to app bar actions in message list page which appears in channel narrows and navigates to the topic list page when pressed Fixes zulip#1158.
1 parent 908a0a3 commit c3e4e36

File tree

3 files changed

+396
-1
lines changed

3 files changed

+396
-1
lines changed

lib/widgets/message_list.dart

+8
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import 'sticky_header.dart';
2424
import 'store.dart';
2525
import 'text.dart';
2626
import 'theme.dart';
27+
import 'topic_list.dart';
2728

2829
/// Message-list styles that differ between light and dark themes.
2930
class MessageListTheme extends ThemeExtension<MessageListTheme> {
@@ -275,6 +276,13 @@ class _MessageListPageState extends State<MessageListPage> implements MessageLis
275276
onPressed: () => Navigator.push(context,
276277
MessageListPage.buildRoute(context: context,
277278
narrow: ChannelNarrow(streamId)))));
279+
} else if (narrow case ChannelNarrow(:final streamId)) {
280+
(actions ??= []).add(IconButton(
281+
icon: const Icon(ZulipIcons.topic),
282+
tooltip: zulipLocalizations.topicListButtonTooltip,
283+
onPressed: () => Navigator.push(context,
284+
TopicListPage.buildRoute(context: context, streamId: streamId, messageListView: model!)),
285+
));
278286
}
279287

280288
// Insert a PageRoot here, to provide a context that can be used for

lib/widgets/topic_list.dart

+273
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
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

Comments
 (0)