Skip to content

Commit f75e83b

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 f75e83b

File tree

1 file changed

+294
-0
lines changed

1 file changed

+294
-0
lines changed

lib/widgets/topic_list.dart

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

Comments
 (0)