Skip to content

Commit c79cd7c

Browse files
committed
RecentDmConversationsPage: Add
The screen's content area (so, the list of conversations, but not the app bar at the top) is built against Vlad's design: https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/design.3A.20DM-conversation.20list/near/1594654 except that some features that appear in that design are left unimplemented for now, since we don't have data structures for them yet: - unread counts - user presence Fixes: zulip#119
1 parent 55f9c4b commit c79cd7c

File tree

5 files changed

+494
-0
lines changed

5 files changed

+494
-0
lines changed

lib/widgets/app.dart

+6
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import '../model/narrow.dart';
44
import 'about_zulip.dart';
55
import 'login.dart';
66
import 'message_list.dart';
7+
import 'recent_dm_conversations.dart';
78
import 'store.dart';
89

910
class ZulipApp extends StatelessWidget {
@@ -152,6 +153,11 @@ class HomePage extends StatelessWidget {
152153
MessageListPage.buildRoute(context: context,
153154
narrow: const AllMessagesNarrow())),
154155
child: const Text("All messages")),
156+
const SizedBox(height: 16),
157+
ElevatedButton(
158+
onPressed: () => Navigator.push(context,
159+
RecentDmConversationsPage.buildRoute(context: context)),
160+
child: const Text("Direct messages")),
155161
if (testStreamId != null) ...[
156162
const SizedBox(height: 16),
157163
ElevatedButton(
+108
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import 'package:flutter/material.dart';
2+
3+
import '../model/narrow.dart';
4+
import '../model/recent_dm_conversations.dart';
5+
import 'content.dart';
6+
import 'icons.dart';
7+
import 'message_list.dart';
8+
import 'page.dart';
9+
import 'store.dart';
10+
import 'text.dart';
11+
12+
class RecentDmConversationsPage extends StatefulWidget {
13+
const RecentDmConversationsPage({super.key});
14+
15+
static Route<void> buildRoute({required BuildContext context}) {
16+
return MaterialAccountPageRoute(context: context,
17+
builder: (context) => const RecentDmConversationsPage());
18+
}
19+
20+
@override
21+
State<RecentDmConversationsPage> createState() => _RecentDmConversationsPageState();
22+
}
23+
24+
class _RecentDmConversationsPageState extends State<RecentDmConversationsPage> with PerAccountStoreAwareStateMixin<RecentDmConversationsPage> {
25+
RecentDmConversationsView? model;
26+
27+
@override
28+
void onNewStore() {
29+
model?.removeListener(_modelChanged);
30+
model = PerAccountStoreWidget.of(context).recentDmConversationsView
31+
..addListener(_modelChanged);
32+
}
33+
34+
void _modelChanged() {
35+
setState(() {
36+
// The actual state lives in [model].
37+
// This method was called because that just changed.
38+
});
39+
}
40+
41+
@override
42+
Widget build(BuildContext context) {
43+
final sorted = model!.sorted;
44+
return Scaffold(
45+
appBar: AppBar(title: const Text('Direct messages')),
46+
body: ListView.builder(
47+
itemCount: sorted.length,
48+
itemBuilder: (context, index) => RecentDmConversationsItem(narrow: sorted[index])));
49+
}
50+
}
51+
52+
class RecentDmConversationsItem extends StatelessWidget {
53+
const RecentDmConversationsItem({super.key, required this.narrow});
54+
55+
final DmNarrow narrow;
56+
57+
@override
58+
Widget build(BuildContext context) {
59+
final store = PerAccountStoreWidget.of(context);
60+
final selfUser = store.users[store.account.userId]!;
61+
62+
final String title;
63+
final Widget avatar;
64+
switch (narrow.otherRecipientIds) {
65+
case []:
66+
title = selfUser.fullName;
67+
avatar = AvatarImage(userId: selfUser.userId);
68+
case [var otherUserId]:
69+
final otherUser = store.users[otherUserId];
70+
title = otherUser?.fullName ?? '(unknown user)';
71+
avatar = AvatarImage(userId: otherUserId);
72+
default:
73+
// TODO(i18n): List formatting, like you can do in JavaScript:
74+
// new Intl.ListFormat('ja').format(['Chris', 'Greg', 'Alya'])
75+
// // 'Chris、Greg、Alya'
76+
title = narrow.otherRecipientIds.map((id) => store.users[id]?.fullName ?? '(unknown user)').join(', ');
77+
avatar = ColoredBox(color: const Color(0x33808080),
78+
child: Center(
79+
child: Icon(ZulipIcons.group_dm, color: Colors.black.withOpacity(0.5))));
80+
}
81+
82+
return InkWell(
83+
onTap: () {
84+
Navigator.push(context,
85+
MessageListPage.buildRoute(context: context, narrow: narrow));
86+
},
87+
child: ConstrainedBox(constraints: const BoxConstraints(minHeight: 48),
88+
child: Row(crossAxisAlignment: CrossAxisAlignment.center, children: [
89+
Padding(padding: const EdgeInsets.fromLTRB(12, 8, 0, 8),
90+
child: AvatarShape(size: 32, borderRadius: 3, child: avatar)),
91+
const SizedBox(width: 8),
92+
Expanded(child: Padding(
93+
padding: const EdgeInsets.symmetric(vertical: 4),
94+
child: Text(
95+
style: const TextStyle(
96+
fontFamily: 'Source Sans 3',
97+
fontSize: 17,
98+
height: (20 / 17),
99+
color: Color(0xFF222222),
100+
).merge(weightVariableTextStyle(context)),
101+
maxLines: 2,
102+
overflow: TextOverflow.ellipsis,
103+
title))),
104+
const SizedBox(width: 8),
105+
// TODO(#253): Unread count
106+
])));
107+
}
108+
}

test/test_navigation.dart

+39
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import 'package:flutter/widgets.dart';
2+
3+
// Inspired by test code in the Flutter tree:
4+
// https://github.com/flutter/flutter/blob/53082f65b/packages/flutter/test/widgets/observer_tester.dart
5+
// https://github.com/flutter/flutter/blob/53082f65b/packages/flutter/test/widgets/navigator_test.dart
6+
7+
/// A trivial observer for testing the navigator.
8+
class TestNavigatorObserver extends NavigatorObserver {
9+
void Function(Route<dynamic> route, Route<dynamic>? previousRoute)? onPushed;
10+
void Function(Route<dynamic> route, Route<dynamic>? previousRoute)? onPopped;
11+
void Function(Route<dynamic> route, Route<dynamic>? previousRoute)? onRemoved;
12+
void Function(Route<dynamic>? route, Route<dynamic>? previousRoute)? onReplaced;
13+
void Function(Route<dynamic> route, Route<dynamic>? previousRoute)? onStartUserGesture;
14+
15+
@override
16+
void didPush(Route<dynamic> route, Route<dynamic>? previousRoute) {
17+
onPushed?.call(route, previousRoute);
18+
}
19+
20+
@override
21+
void didPop(Route<dynamic> route, Route<dynamic>? previousRoute) {
22+
onPopped?.call(route, previousRoute);
23+
}
24+
25+
@override
26+
void didRemove(Route<dynamic> route, Route<dynamic>? previousRoute) {
27+
onRemoved?.call(route, previousRoute);
28+
}
29+
30+
@override
31+
void didReplace({ Route<dynamic>? oldRoute, Route<dynamic>? newRoute }) {
32+
onReplaced?.call(newRoute, oldRoute);
33+
}
34+
35+
@override
36+
void didStartUserGesture(Route<dynamic> route, Route<dynamic>? previousRoute) {
37+
onStartUserGesture?.call(route, previousRoute);
38+
}
39+
}

test/widgets/content_checks.dart

+12
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,19 @@
11
import 'package:checks/checks.dart';
2+
import 'package:flutter/widgets.dart';
3+
24
import 'package:zulip/widgets/content.dart';
35

46
extension RealmContentNetworkImageChecks on Subject<RealmContentNetworkImage> {
57
Subject<Uri> get src => has((i) => i.src, 'src');
68
// TODO others
79
}
10+
11+
extension AvatarImageChecks on Subject<AvatarImage> {
12+
Subject<int> get userId => has((i) => i.userId, 'userId');
13+
}
14+
15+
extension AvatarShapeChecks on Subject<AvatarShape> {
16+
Subject<double> get size => has((i) => i.size, 'size');
17+
Subject<double> get borderRadius => has((i) => i.borderRadius, 'borderRadius');
18+
Subject<Widget> get child => has((i) => i.child, 'child');
19+
}

0 commit comments

Comments
 (0)