Skip to content

Commit

Permalink
profile: Implement profile screen for users
Browse files Browse the repository at this point in the history
Added profile screen with user information and custom profile
fields, linked from sender name and avatar in message list.

User presence (zulip#196) and user status (zulip#197) are not
yet displayed or tracked here.

Fixes: zulip#195
  • Loading branch information
sirpengi committed Sep 7, 2023
1 parent 55f3e52 commit c0f7552
Show file tree
Hide file tree
Showing 5 changed files with 604 additions and 4 deletions.
15 changes: 12 additions & 3 deletions lib/widgets/message_list.dart
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import 'compose_box.dart';
import 'content.dart';
import 'icons.dart';
import 'page.dart';
import 'profile.dart';
import 'sticky_header.dart';
import 'store.dart';

Expand Down Expand Up @@ -580,14 +581,22 @@ class MessageWithSender extends StatelessWidget {
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
Padding(
padding: const EdgeInsets.fromLTRB(3, 6, 11, 0),
child: Avatar(userId: message.senderId, size: 35, borderRadius: 4)),
child: GestureDetector(
onTap: () => Navigator.push(context,
ProfilePage.buildRoute(context: context,
userId: message.senderId)),
child: Avatar(userId: message.senderId, size: 35, borderRadius: 4))),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 3),
Text(message.senderFullName, // TODO get from user data
style: const TextStyle(fontWeight: FontWeight.bold)),
GestureDetector(
onTap: () => Navigator.push(context,
ProfilePage.buildRoute(context: context,
userId: message.senderId)),
child: Text(message.senderFullName, // TODO get from user data
style: const TextStyle(fontWeight: FontWeight.bold))),
const SizedBox(height: 4),
MessageContent(message: message, content: content),
])),
Expand Down
261 changes: 261 additions & 0 deletions lib/widgets/profile.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,261 @@
import 'dart:convert';

import 'package:flutter/material.dart';

import '../api/model/model.dart';
import '../model/content.dart';
import '../model/narrow.dart';
import 'content.dart';
import 'message_list.dart';
import 'page.dart';
import 'store.dart';

class _TextStyles {
static const primaryFieldText = TextStyle(fontSize: 20);
static const customProfileFieldLabel = TextStyle(fontSize: 15, fontWeight: FontWeight.bold);
static const customProfileFieldText = TextStyle(fontSize: 15);
}

class ProfilePage extends StatelessWidget {
const ProfilePage({super.key, required this.userId});

final int userId;

static Route<void> buildRoute({required BuildContext context, required int userId}) {
return MaterialAccountWidgetRoute(context: context,
page: ProfilePage(userId: userId));
}

@override
Widget build(BuildContext context) {
final store = PerAccountStoreWidget.of(context);
final user = store.users[userId];
if (user == null) {
return const _ProfileErrorPage();
}

final items = [
Center(
child: Avatar(userId: userId, size: 200, borderRadius: 200 / 8)),
const SizedBox(height: 16),
Text(user.fullName,
textAlign: TextAlign.center,
style: _TextStyles.primaryFieldText.merge(const TextStyle(fontWeight: FontWeight.bold))),
// TODO(#291) render email field
Text(roleToLabel(user.role),
textAlign: TextAlign.center,
style: _TextStyles.primaryFieldText),
// TODO(#197) render user status
// TODO(#196) render active status
// TODO(#292) render user local time

_ProfileDataTable(profileData: user.profileData),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: () => Navigator.push(context,
MessageListPage.buildRoute(context: context,
narrow: DmNarrow.withUser(userId, selfUserId: store.account.userId))),
icon: const Icon(Icons.email),
label: const Text('Send direct message')),
];

return Scaffold(
appBar: AppBar(title: Text(user.fullName)),
body: SingleChildScrollView(
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 760),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: items))))));
}
}

class _ProfileErrorPage extends StatelessWidget {
const _ProfileErrorPage();

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Error')),
body: const SingleChildScrollView(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 32),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error),
SizedBox(width: 4),
Text('Could not show user profile.'),
]))));
}
}

String roleToLabel(UserRole role) {
return switch (role) {
UserRole.owner => 'Owner',
UserRole.administrator => 'Administrator',
UserRole.moderator => 'Moderator',
UserRole.member => 'Member',
UserRole.guest => 'Guest',
UserRole.unknown => 'Unknown',
};
}

class _ProfileDataTable extends StatelessWidget {
const _ProfileDataTable({required this.profileData});

final Map<int, ProfileFieldUserData>? profileData;

static T? _tryDecode<T, U>(T Function(U) fromJson, String data) {
try {
return fromJson(jsonDecode(data));
} on FormatException {
return null;
} on TypeError {
return null;
}
}

Widget? _buildCustomProfileFieldValue(BuildContext context, String value, CustomProfileField realmField) {
final store = PerAccountStoreWidget.of(context);

switch (realmField.type) {
case CustomProfileFieldType.link:
return _LinkWidget(url: value, text: value);

case CustomProfileFieldType.choice:
final choiceFieldData = _tryDecode(CustomProfileFieldChoiceDataItem.parseFieldDataChoices, realmField.fieldData);
if (choiceFieldData == null) return null;
final choiceItem = choiceFieldData[value];
return (choiceItem == null) ? null : _TextWidget(text: choiceItem.text);

case CustomProfileFieldType.externalAccount:
final externalAccountFieldData = _tryDecode(CustomProfileFieldExternalAccountData.fromJson, realmField.fieldData);
if (externalAccountFieldData == null) return null;
final urlPattern = externalAccountFieldData.urlPattern ??
store.realmDefaultExternalAccounts[externalAccountFieldData.subtype]?.urlPattern;
if (urlPattern == null) return null;
final url = urlPattern.replaceFirst('%(username)s', value);
return _LinkWidget(url: url, text: value);

case CustomProfileFieldType.user:
// TODO(server): This is completely undocumented. The key to
// reverse-engineering it was:
// https://github.com/zulip/zulip/blob/18230fcd9/static/js/settings_account.js#L247
final userIds = _tryDecode((List<dynamic> json) {
return json.map((e) => e as int).toList();
}, value);
if (userIds == null) return null;
return Column(
children: userIds.map((userId) => _UserWidget(userId: userId)).toList());

case CustomProfileFieldType.date:
// TODO(server): The value's format is undocumented, but empirically
// it's a date in ISO format, like 2000-01-01.
// That's readable as is, but:
// TODO format this date using user's locale.
return _TextWidget(text: value);

case CustomProfileFieldType.shortText:
case CustomProfileFieldType.longText:
case CustomProfileFieldType.pronouns:
// The web client appears to treat `longText` identically to `shortText`;
// `pronouns` is explicitly meant to display the same as `shortText`.
return _TextWidget(text: value);

case CustomProfileFieldType.unknown:
return null;
}
}

@override
Widget build(BuildContext context) {
final store = PerAccountStoreWidget.of(context);
if (profileData == null) return const SizedBox.shrink();

List<Widget> items = [];

for (final realmField in store.customProfileFields) {
final profileField = profileData![realmField.id];
if (profileField == null) continue;
final widget = _buildCustomProfileFieldValue(context, profileField.value, realmField);
if (widget == null) continue; // TODO(log)

items.add(Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
SizedBox(width: 96,
child: Text(realmField.name, style: _TextStyles.customProfileFieldLabel)),
const SizedBox(width: 8),
Flexible(child: widget),
]));
items.add(const SizedBox(height: 8));
}

if (items.isEmpty) return const SizedBox.shrink();

return Column(children: [
const SizedBox(height: 16),
...items
]);
}
}

class _LinkWidget extends StatelessWidget {
const _LinkWidget({required this.url, required this.text});

final String url;
final String text;

@override
Widget build(BuildContext context) {
final linkNode = LinkNode(url: url, nodes: [TextNode(text)]);
final paragraph = Paragraph(node: ParagraphNode(nodes: [linkNode], links: [linkNode]));
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: paragraph));
}
}

class _TextWidget extends StatelessWidget {
const _TextWidget({required this.text});

final String text;

@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text(text, style: _TextStyles.customProfileFieldText));
}
}

class _UserWidget extends StatelessWidget {
const _UserWidget({required this.userId});

final int userId;

@override
Widget build(BuildContext context) {
final store = PerAccountStoreWidget.of(context);
final user = store.users[userId];
final fullName = user?.fullName ?? '(unknown user)';
return InkWell(
onTap: () => Navigator.push(context,
ProfilePage.buildRoute(context: context,
userId: userId)),
child: Padding(
padding: const EdgeInsets.all(8),
child: Row(children: [
Avatar(userId: userId, size: 32, borderRadius: 32 / 8),
const SizedBox(width: 8),
Expanded(child: Text(fullName, style: _TextStyles.customProfileFieldText)), // TODO(#196) render active status
])));
}
}
3 changes: 2 additions & 1 deletion test/example_data.dart
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ User user({
String? email,
String? fullName,
String? avatarUrl,
Map<int, ProfileFieldUserData>? profileData,
}) {
return User(
userId: userId ?? 123, // TODO generate example IDs
Expand All @@ -32,7 +33,7 @@ User user({
timezone: 'UTC',
avatarUrl: avatarUrl,
avatarVersion: 0,
profileData: null,
profileData: profileData,
);
}

Expand Down
6 changes: 6 additions & 0 deletions test/widgets/profile_page_checks.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import 'package:checks/checks.dart';
import 'package:zulip/widgets/profile.dart';

extension ProfilePageChecks on Subject<ProfilePage> {
Subject<int> get userId => has((x) => x.userId, 'userId');
}
Loading

0 comments on commit c0f7552

Please sign in to comment.