Skip to content

Commit f11c52f

Browse files
chrisbobbegnprice
authored andcommitted
presence: Show presence on avatars throughout the app, and in profile
We plan to write tests for this as a followup: #1620. Fixes: #1607
1 parent 5d43df2 commit f11c52f

File tree

6 files changed

+224
-9
lines changed

6 files changed

+224
-9
lines changed

lib/widgets/content.dart

Lines changed: 155 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,11 @@ import '../api/core.dart';
1212
import '../api/model/model.dart';
1313
import '../generated/l10n/zulip_localizations.dart';
1414
import '../model/avatar_url.dart';
15+
import '../model/binding.dart';
1516
import '../model/content.dart';
1617
import '../model/internal_link.dart';
1718
import '../model/katex.dart';
19+
import '../model/presence.dart';
1820
import 'actions.dart';
1921
import 'code_block.dart';
2022
import 'dialog.dart';
@@ -1662,17 +1664,26 @@ class Avatar extends StatelessWidget {
16621664
required this.userId,
16631665
required this.size,
16641666
required this.borderRadius,
1667+
this.backgroundColor,
1668+
this.showPresence = true,
16651669
});
16661670

16671671
final int userId;
16681672
final double size;
16691673
final double borderRadius;
1674+
final Color? backgroundColor;
1675+
final bool showPresence;
16701676

16711677
@override
16721678
Widget build(BuildContext context) {
1679+
// (The backgroundColor is only meaningful if presence will be shown;
1680+
// see [PresenceCircle.backgroundColor].)
1681+
assert(backgroundColor == null || showPresence);
16731682
return AvatarShape(
16741683
size: size,
16751684
borderRadius: borderRadius,
1685+
backgroundColor: backgroundColor,
1686+
userIdForPresence: showPresence ? userId : null,
16761687
child: AvatarImage(userId: userId, size: size));
16771688
}
16781689
}
@@ -1722,26 +1733,169 @@ class AvatarImage extends StatelessWidget {
17221733
}
17231734

17241735
/// A rounded square shape, to wrap an [AvatarImage] or similar.
1736+
///
1737+
/// If [userIdForPresence] is provided, this will paint a [PresenceCircle]
1738+
/// on the shape.
17251739
class AvatarShape extends StatelessWidget {
17261740
const AvatarShape({
17271741
super.key,
17281742
required this.size,
17291743
required this.borderRadius,
1744+
this.backgroundColor,
1745+
this.userIdForPresence,
17301746
required this.child,
17311747
});
17321748

17331749
final double size;
17341750
final double borderRadius;
1751+
final Color? backgroundColor;
1752+
final int? userIdForPresence;
17351753
final Widget child;
17361754

17371755
@override
17381756
Widget build(BuildContext context) {
1739-
return SizedBox.square(
1757+
// (The backgroundColor is only meaningful if presence will be shown;
1758+
// see [PresenceCircle.backgroundColor].)
1759+
assert(backgroundColor == null || userIdForPresence != null);
1760+
1761+
Widget result = SizedBox.square(
17401762
dimension: size,
17411763
child: ClipRRect(
17421764
borderRadius: BorderRadius.all(Radius.circular(borderRadius)),
17431765
clipBehavior: Clip.antiAlias,
17441766
child: child));
1767+
1768+
if (userIdForPresence != null) {
1769+
final presenceCircleSize = size / 4; // TODO(design) is this right?
1770+
result = Stack(children: [
1771+
result,
1772+
Positioned.directional(textDirection: Directionality.of(context),
1773+
end: 0,
1774+
bottom: 0,
1775+
child: PresenceCircle(
1776+
userId: userIdForPresence!,
1777+
size: presenceCircleSize,
1778+
backgroundColor: backgroundColor)),
1779+
]);
1780+
}
1781+
1782+
return result;
1783+
}
1784+
}
1785+
1786+
/// The green or orange-gradient circle representing [PresenceStatus].
1787+
///
1788+
/// [backgroundColor] must not be [Colors.transparent].
1789+
/// It exists to match the background on which the avatar image is painted.
1790+
/// If [backgroundColor] is not passed, [DesignVariables.mainBackground] is used.
1791+
///
1792+
/// By default, nothing paints for a user in the "offline" status
1793+
/// (i.e. a user without a [PresenceStatus]).
1794+
/// Pass true for [explicitOffline] to paint a gray circle.
1795+
class PresenceCircle extends StatefulWidget {
1796+
const PresenceCircle({
1797+
super.key,
1798+
required this.userId,
1799+
required this.size,
1800+
this.backgroundColor,
1801+
this.explicitOffline = false,
1802+
});
1803+
1804+
final int userId;
1805+
final double size;
1806+
final Color? backgroundColor;
1807+
final bool explicitOffline;
1808+
1809+
/// Creates a [WidgetSpan] with a [PresenceCircle], for use in rich text
1810+
/// before a user's name.
1811+
///
1812+
/// The [PresenceCircle] will have `explicitOffline: true`.
1813+
static InlineSpan asWidgetSpan({
1814+
required int userId,
1815+
required double fontSize,
1816+
required TextScaler textScaler,
1817+
Color? backgroundColor,
1818+
}) {
1819+
final size = textScaler.scale(fontSize) / 2;
1820+
return WidgetSpan(
1821+
alignment: PlaceholderAlignment.middle,
1822+
child: Padding(
1823+
padding: const EdgeInsetsDirectional.only(end: 4),
1824+
child: PresenceCircle(
1825+
userId: userId,
1826+
size: size,
1827+
backgroundColor: backgroundColor,
1828+
explicitOffline: true)));
1829+
}
1830+
1831+
@override
1832+
State<PresenceCircle> createState() => _PresenceCircleState();
1833+
}
1834+
1835+
class _PresenceCircleState extends State<PresenceCircle> with PerAccountStoreAwareStateMixin {
1836+
Presence? model;
1837+
1838+
@override
1839+
void onNewStore() {
1840+
model?.removeListener(_modelChanged);
1841+
model = PerAccountStoreWidget.of(context).presence
1842+
..addListener(_modelChanged);
1843+
}
1844+
1845+
@override
1846+
void dispose() {
1847+
model!.removeListener(_modelChanged);
1848+
super.dispose();
1849+
}
1850+
1851+
void _modelChanged() {
1852+
setState(() {
1853+
// The actual state lives in [model].
1854+
// This method was called because that just changed.
1855+
});
1856+
}
1857+
1858+
@override
1859+
Widget build(BuildContext context) {
1860+
final status = model!.presenceStatusForUser(
1861+
widget.userId, utcNow: ZulipBinding.instance.utcNow());
1862+
final designVariables = DesignVariables.of(context);
1863+
final effectiveBackgroundColor = widget.backgroundColor ?? designVariables.mainBackground;
1864+
assert(effectiveBackgroundColor != Colors.transparent);
1865+
1866+
Color? color;
1867+
LinearGradient? gradient;
1868+
switch (status) {
1869+
case null:
1870+
if (widget.explicitOffline) {
1871+
// TODO(a11y) this should be an open circle, like on web,
1872+
// to differentiate by shape (vs. the "active" status which is also
1873+
// a solid circle)
1874+
color = designVariables.statusAway;
1875+
} else {
1876+
return SizedBox.square(dimension: widget.size);
1877+
}
1878+
case PresenceStatus.active:
1879+
color = designVariables.statusOnline;
1880+
case PresenceStatus.idle:
1881+
gradient = LinearGradient(
1882+
begin: AlignmentDirectional.centerStart,
1883+
end: AlignmentDirectional.centerEnd,
1884+
colors: [designVariables.statusIdle, effectiveBackgroundColor],
1885+
stops: [0.05, 1.00],
1886+
);
1887+
}
1888+
1889+
return SizedBox.square(dimension: widget.size,
1890+
child: DecoratedBox(
1891+
decoration: BoxDecoration(
1892+
border: Border.all(
1893+
color: effectiveBackgroundColor,
1894+
width: 2,
1895+
strokeAlign: BorderSide.strokeAlignOutside),
1896+
color: color,
1897+
gradient: gradient,
1898+
shape: BoxShape.circle)));
17451899
}
17461900
}
17471901

lib/widgets/home.dart

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -570,7 +570,11 @@ class _MyProfileButton extends _MenuButton {
570570
Widget buildLeading(BuildContext context) {
571571
final store = PerAccountStoreWidget.of(context);
572572
return Avatar(
573-
userId: store.selfUserId, size: _MenuButton._iconSize, borderRadius: 4);
573+
userId: store.selfUserId,
574+
size: _MenuButton._iconSize,
575+
borderRadius: 4,
576+
showPresence: false,
577+
);
574578
}
575579

576580
@override

lib/widgets/message_list.dart

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1704,7 +1704,10 @@ class _SenderRow extends StatelessWidget {
17041704
userId: message.senderId)),
17051705
child: Row(
17061706
children: [
1707-
Avatar(size: 32, borderRadius: 3,
1707+
Avatar(
1708+
size: 32,
1709+
borderRadius: 3,
1710+
showPresence: false,
17081711
userId: message.senderId),
17091712
const SizedBox(width: 8),
17101713
Flexible(

lib/widgets/profile.dart

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,15 +44,31 @@ class ProfilePage extends StatelessWidget {
4444
return const _ProfileErrorPage();
4545
}
4646

47+
final nameStyle = _TextStyles.primaryFieldText
48+
.merge(weightVariableTextStyle(context, wght: 700));
49+
4750
final displayEmail = store.userDisplayEmail(user);
4851
final items = [
4952
Center(
50-
child: Avatar(userId: userId, size: 200, borderRadius: 200 / 8)),
53+
child: Avatar(
54+
userId: userId,
55+
size: 200,
56+
borderRadius: 200 / 8,
57+
// Would look odd with this large image;
58+
// we'll show it by the user's name instead.
59+
showPresence: false)),
5160
const SizedBox(height: 16),
52-
Text(user.fullName,
61+
Text.rich(
62+
TextSpan(children: [
63+
PresenceCircle.asWidgetSpan(
64+
userId: userId,
65+
fontSize: nameStyle.fontSize!,
66+
textScaler: MediaQuery.textScalerOf(context),
67+
),
68+
TextSpan(text: user.fullName),
69+
]),
5370
textAlign: TextAlign.center,
54-
style: _TextStyles.primaryFieldText
55-
.merge(weightVariableTextStyle(context, wght: 700))),
71+
style: nameStyle),
5672
if (displayEmail != null)
5773
Text(displayEmail,
5874
textAlign: TextAlign.center,

lib/widgets/recent_dm_conversations.dart

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ class RecentDmConversationsItem extends StatelessWidget {
101101

102102
final String title;
103103
final Widget avatar;
104+
int? userIdForPresence;
104105
switch (narrow.otherRecipientIds) { // TODO dedupe with DM items in [InboxPage]
105106
case []:
106107
title = store.selfUser.fullName;
@@ -111,6 +112,7 @@ class RecentDmConversationsItem extends StatelessWidget {
111112
// 1:1 DM conversations from muted users?)
112113
title = store.userDisplayName(otherUserId);
113114
avatar = AvatarImage(userId: otherUserId, size: _avatarSize);
115+
userIdForPresence = otherUserId;
114116
default:
115117
// TODO(i18n): List formatting, like you can do in JavaScript:
116118
// new Intl.ListFormat('ja').format(['Chris', 'Greg', 'Alya'])
@@ -123,8 +125,10 @@ class RecentDmConversationsItem extends StatelessWidget {
123125
ZulipIcons.group_dm)));
124126
}
125127

128+
// TODO(design) check if this is the right variable
129+
final backgroundColor = designVariables.background;
126130
return Material(
127-
color: designVariables.background, // TODO(design) check if this is the right variable
131+
color: backgroundColor,
128132
child: InkWell(
129133
onTap: () {
130134
Navigator.push(context,
@@ -133,7 +137,12 @@ class RecentDmConversationsItem extends StatelessWidget {
133137
child: ConstrainedBox(constraints: const BoxConstraints(minHeight: 48),
134138
child: Row(crossAxisAlignment: CrossAxisAlignment.center, children: [
135139
Padding(padding: const EdgeInsetsDirectional.fromSTEB(12, 8, 0, 8),
136-
child: AvatarShape(size: _avatarSize, borderRadius: 3, child: avatar)),
140+
child: AvatarShape(
141+
size: _avatarSize,
142+
borderRadius: 3,
143+
backgroundColor: userIdForPresence != null ? backgroundColor : null,
144+
userIdForPresence: userIdForPresence,
145+
child: avatar)),
137146
const SizedBox(width: 8),
138147
Expanded(child: Padding(
139148
padding: const EdgeInsets.symmetric(vertical: 4),

lib/widgets/theme.dart

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,13 @@ class DesignVariables extends ThemeExtension<DesignVariables> {
173173
mainBackground: const Color(0xfff0f0f0),
174174
radioBorder: Color(0xffbbbdc8),
175175
radioFillSelected: Color(0xff4370f0),
176+
statusAway: Color(0xff73788c).withValues(alpha: 0.25),
177+
178+
// Following Web because it uses a gradient, to distinguish it by shape from
179+
// the "active" dot, and the Figma doesn't; Figma just has solid #d5bb6c.
180+
statusIdle: Color(0xfff5b266),
181+
182+
statusOnline: Color(0xff46aa62),
176183
textInput: const Color(0xff000000),
177184
title: const Color(0xff1a1a1a),
178185
bgSearchInput: const Color(0xffe3e3e3),
@@ -242,6 +249,13 @@ class DesignVariables extends ThemeExtension<DesignVariables> {
242249
mainBackground: const Color(0xff1d1d1d),
243250
radioBorder: Color(0xff626573),
244251
radioFillSelected: Color(0xff4e7cfa),
252+
statusAway: Color(0xffabaeba).withValues(alpha: 0.30),
253+
254+
// Following Web because it uses a gradient, to distinguish it by shape from
255+
// the "active" dot, and the Figma doesn't; Figma just has solid #8c853b.
256+
statusIdle: Color(0xffae640a),
257+
258+
statusOnline: Color(0xff44bb66),
245259
textInput: const Color(0xffffffff).withValues(alpha: 0.9),
246260
title: const Color(0xffffffff).withValues(alpha: 0.9),
247261
bgSearchInput: const Color(0xff313131),
@@ -319,6 +333,9 @@ class DesignVariables extends ThemeExtension<DesignVariables> {
319333
required this.mainBackground,
320334
required this.radioBorder,
321335
required this.radioFillSelected,
336+
required this.statusAway,
337+
required this.statusIdle,
338+
required this.statusOnline,
322339
required this.textInput,
323340
required this.title,
324341
required this.bgSearchInput,
@@ -397,6 +414,9 @@ class DesignVariables extends ThemeExtension<DesignVariables> {
397414
final Color mainBackground;
398415
final Color radioBorder;
399416
final Color radioFillSelected;
417+
final Color statusAway;
418+
final Color statusIdle;
419+
final Color statusOnline;
400420
final Color textInput;
401421
final Color title;
402422
final Color bgSearchInput;
@@ -470,6 +490,9 @@ class DesignVariables extends ThemeExtension<DesignVariables> {
470490
Color? mainBackground,
471491
Color? radioBorder,
472492
Color? radioFillSelected,
493+
Color? statusAway,
494+
Color? statusIdle,
495+
Color? statusOnline,
473496
Color? textInput,
474497
Color? title,
475498
Color? bgSearchInput,
@@ -538,6 +561,9 @@ class DesignVariables extends ThemeExtension<DesignVariables> {
538561
mainBackground: mainBackground ?? this.mainBackground,
539562
radioBorder: radioBorder ?? this.radioBorder,
540563
radioFillSelected: radioFillSelected ?? this.radioFillSelected,
564+
statusAway: statusAway ?? this.statusAway,
565+
statusIdle: statusIdle ?? this.statusIdle,
566+
statusOnline: statusOnline ?? this.statusOnline,
541567
textInput: textInput ?? this.textInput,
542568
title: title ?? this.title,
543569
bgSearchInput: bgSearchInput ?? this.bgSearchInput,
@@ -613,6 +639,9 @@ class DesignVariables extends ThemeExtension<DesignVariables> {
613639
mainBackground: Color.lerp(mainBackground, other.mainBackground, t)!,
614640
radioBorder: Color.lerp(radioBorder, other.radioBorder, t)!,
615641
radioFillSelected: Color.lerp(radioFillSelected, other.radioFillSelected, t)!,
642+
statusAway: Color.lerp(statusAway, other.statusAway, t)!,
643+
statusIdle: Color.lerp(statusIdle, other.statusIdle, t)!,
644+
statusOnline: Color.lerp(statusOnline, other.statusOnline, t)!,
616645
textInput: Color.lerp(textInput, other.textInput, t)!,
617646
title: Color.lerp(title, other.title, t)!,
618647
bgSearchInput: Color.lerp(bgSearchInput, other.bgSearchInput, t)!,

0 commit comments

Comments
 (0)