Skip to content

Commit 40ed9b2

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 Fixes part of zulip#1158.
1 parent 908a0a3 commit 40ed9b2

File tree

1 file changed

+275
-0
lines changed

1 file changed

+275
-0
lines changed

lib/widgets/topic_list.dart

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

Comments
 (0)