@@ -12,9 +12,11 @@ import '../api/core.dart';
1212import '../api/model/model.dart' ;
1313import '../generated/l10n/zulip_localizations.dart' ;
1414import '../model/avatar_url.dart' ;
15+ import '../model/binding.dart' ;
1516import '../model/content.dart' ;
1617import '../model/internal_link.dart' ;
1718import '../model/katex.dart' ;
19+ import '../model/presence.dart' ;
1820import 'actions.dart' ;
1921import 'code_block.dart' ;
2022import '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.
17251739class 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
0 commit comments