diff --git a/lib/api/core.dart b/lib/api/core.dart index b1b797eebd..8a9ce60d43 100644 --- a/lib/api/core.dart +++ b/lib/api/core.dart @@ -104,7 +104,7 @@ class ApiConnection { Future get(String routeName, T Function(Map) fromJson, String path, Map? params) async { final url = realmUrl.replace( - path: "/api/v1/$path", queryParameters: encodeParameters(params)); + path: "/api/v1/$path", queryParameters: encodeParameters(params)); final request = http.Request('GET', url); return send(routeName, fromJson, request); } @@ -167,5 +167,5 @@ Map authHeader({required String email, required String apiKey}) Map? encodeParameters(Map? params) { return params?.map((k, v) => - MapEntry(k, v is RawParameter ? v.value : jsonEncode(v))); + MapEntry(k, v is RawParameter ? v.value : jsonEncode(v))); } diff --git a/lib/api/model/events.dart b/lib/api/model/events.dart index d166757d74..f8d89a1bac 100644 --- a/lib/api/model/events.dart +++ b/lib/api/model/events.dart @@ -45,7 +45,7 @@ class UnexpectedEvent extends Event { UnexpectedEvent({required super.id, required this.json}); factory UnexpectedEvent.fromJson(Map json) => - UnexpectedEvent(id: json['id'] as int, json: json); + UnexpectedEvent(id: json['id'] as int, json: json); @override Map toJson() => json; @@ -63,7 +63,7 @@ class AlertWordsEvent extends Event { AlertWordsEvent({required super.id, required this.alertWords}); factory AlertWordsEvent.fromJson(Map json) => - _$AlertWordsEventFromJson(json); + _$AlertWordsEventFromJson(json); @override Map toJson() => _$AlertWordsEventToJson(this); @@ -199,12 +199,11 @@ class MessageEvent extends Event { MessageEvent({required super.id, required this.message}); factory MessageEvent.fromJson(Map json) => MessageEvent( - id: json['id'] as int, - message: Message.fromJson({ - ...json['message'] as Map, - 'flags': - (json['flags'] as List).map((e) => e as String).toList(), - }), + id: json['id'] as int, + message: Message.fromJson({ + ...json['message'] as Map, + 'flags': (json['flags'] as List).map((e) => e as String).toList(), + }), ); @override @@ -225,7 +224,7 @@ class HeartbeatEvent extends Event { HeartbeatEvent({required super.id}); factory HeartbeatEvent.fromJson(Map json) => - _$HeartbeatEventFromJson(json); + _$HeartbeatEventFromJson(json); @override Map toJson() => _$HeartbeatEventToJson(this); diff --git a/lib/api/model/initial_snapshot.dart b/lib/api/model/initial_snapshot.dart index 3b620a2b67..bfd505c9af 100644 --- a/lib/api/model/initial_snapshot.dart +++ b/lib/api/model/initial_snapshot.dart @@ -66,7 +66,7 @@ class InitialSnapshot { }); factory InitialSnapshot.fromJson(Map json) => - _$InitialSnapshotFromJson(json); + _$InitialSnapshotFromJson(json); Map toJson() => _$InitialSnapshotToJson(this); } diff --git a/lib/api/model/model.dart b/lib/api/model/model.dart index e3ecd2a2d9..00daeae420 100644 --- a/lib/api/model/model.dart +++ b/lib/api/model/model.dart @@ -26,7 +26,7 @@ class CustomProfileField { }); factory CustomProfileField.fromJson(Map json) => - _$CustomProfileFieldFromJson(json); + _$CustomProfileFieldFromJson(json); Map toJson() => _$CustomProfileFieldToJson(this); } @@ -87,8 +87,9 @@ class User { return (value != null && value.isNotEmpty) ? value : null; } - static bool? _readIsSystemBot(Map json, String key) => - json[key] ?? json['is_cross_realm_bot']; + static bool? _readIsSystemBot(Map json, String key) { + return json[key] ?? json['is_cross_realm_bot']; + } User({ required this.userId, @@ -183,7 +184,7 @@ class Subscription { }); factory Subscription.fromJson(Map json) => - _$SubscriptionFromJson(json); + _$SubscriptionFromJson(json); Map toJson() => _$SubscriptionToJson(this); } @@ -281,7 +282,7 @@ class StreamMessage extends Message { }); factory StreamMessage.fromJson(Map json) => - _$StreamMessageFromJson(json); + _$StreamMessageFromJson(json); @override Map toJson() => _$StreamMessageToJson(this); @@ -299,7 +300,7 @@ class PmRecipient { PmRecipient({required this.id, required this.email, required this.fullName}); factory PmRecipient.fromJson(Map json) => - _$PmRecipientFromJson(json); + _$PmRecipientFromJson(json); Map toJson() => _$PmRecipientToJson(this); } @@ -334,7 +335,7 @@ class PmMessage extends Message { }); factory PmMessage.fromJson(Map json) => - _$PmMessageFromJson(json); + _$PmMessageFromJson(json); @override Map toJson() => _$PmMessageToJson(this); diff --git a/lib/api/route/events.dart b/lib/api/route/events.dart index 9f7269e3b9..7846831f32 100644 --- a/lib/api/route/events.dart +++ b/lib/api/route/events.dart @@ -44,7 +44,7 @@ class GetEventsResult { }); factory GetEventsResult.fromJson(Map json) => - _$GetEventsResultFromJson(json); + _$GetEventsResultFromJson(json); Map toJson() => _$GetEventsResultToJson(this); } diff --git a/lib/api/route/messages.dart b/lib/api/route/messages.dart index 7f24b583bc..a616f84391 100644 --- a/lib/api/route/messages.dart +++ b/lib/api/route/messages.dart @@ -89,7 +89,7 @@ class SendMessageResult { }); factory SendMessageResult.fromJson(Map json) => - _$SendMessageResultFromJson(json); + _$SendMessageResultFromJson(json); Map toJson() => _$SendMessageResultToJson(this); } @@ -114,7 +114,7 @@ class UploadFileResult { }); factory UploadFileResult.fromJson(Map json) => - _$UploadFileResultFromJson(json); + _$UploadFileResultFromJson(json); Map toJson() => _$UploadFileResultToJson(this); } diff --git a/lib/model/content.dart b/lib/model/content.dart index 3858d3e414..73367c8968 100644 --- a/lib/model/content.dart +++ b/lib/model/content.dart @@ -75,7 +75,7 @@ class LineBreakNode extends BlockContentNode { // See also [parseImplicitParagraphBlockContentList]. class ParagraphNode extends BlockContentNode { const ParagraphNode( - {super.debugHtmlNode, this.wasImplicit = false, required this.nodes}); + {super.debugHtmlNode, this.wasImplicit = false, required this.nodes}); /// True when there was no corresponding `p` element in the original HTML. final bool wasImplicit; @@ -226,8 +226,7 @@ final _emojiClassRegexp = RegExp(r"^emoji(-[0-9a-f]+)?$"); InlineContentNode parseInlineContent(dom.Node node) { final debugHtmlNode = kDebugMode ? node : null; - InlineContentNode unimplemented() => - UnimplementedInlineContentNode(htmlNode: node); + InlineContentNode unimplemented() => UnimplementedInlineContentNode(htmlNode: node); if (node is dom.Text) { return TextNode(node.text, debugHtmlNode: debugHtmlNode); @@ -239,8 +238,9 @@ InlineContentNode parseInlineContent(dom.Node node) { final element = node; final localName = element.localName; final classes = element.classes; - List nodes() => - element.nodes.map(parseInlineContent).toList(growable: false); + List nodes() { + return element.nodes.map(parseInlineContent).toList(growable: false); + } if (localName == 'br' && classes.isEmpty) { return LineBreakInlineNode(debugHtmlNode: debugHtmlNode); @@ -317,9 +317,9 @@ BlockContentNode parseListNode(dom.Element element) { BlockContentNode parseCodeBlock(dom.Element divElement) { final mainElement = () { - assert(divElement.localName == 'div' && - divElement.classes.length == 1 && - divElement.classes.contains("codehilite")); + assert(divElement.localName == 'div' + && divElement.classes.length == 1 + && divElement.classes.contains("codehilite")); if (divElement.nodes.length != 1) return null; final child = divElement.nodes[0]; @@ -329,9 +329,9 @@ BlockContentNode parseCodeBlock(dom.Element divElement) { if (child.nodes.length > 2) return null; if (child.nodes.length == 2) { final first = child.nodes[0]; - if (first is! dom.Element || - first.localName != 'span' || - first.nodes.isNotEmpty) return null; + if (first is! dom.Element + || first.localName != 'span' + || first.nodes.isNotEmpty) return null; } final grandchild = child.nodes[child.nodes.length - 1]; if (grandchild is! dom.Element) return null; @@ -371,8 +371,8 @@ BlockContentNode parseCodeBlock(dom.Element divElement) { BlockContentNode parseImageNode(dom.Element divElement) { final imgElement = () { assert(divElement.localName == 'div' - && divElement.classes.length == 1 - && divElement.classes.contains('message_inline_image')); + && divElement.classes.length == 1 + && divElement.classes.contains('message_inline_image')); if (divElement.nodes.length != 1) return null; final child = divElement.nodes[0]; @@ -410,8 +410,9 @@ BlockContentNode parseBlockContent(dom.Node node) { final localName = element.localName; final classes = element.classes; List blockNodes() => parseBlockContentList(element.nodes); - List inlineNodes() => - element.nodes.map(parseInlineContent).toList(growable: false); + List inlineNodes() { + return element.nodes.map(parseInlineContent).toList(growable: false); + } if (localName == 'br' && classes.isEmpty) { return LineBreakNode(debugHtmlNode: debugHtmlNode); @@ -437,7 +438,7 @@ BlockContentNode parseBlockContent(dom.Node node) { if (headingLevel == HeadingLevel.h6 && classes.isEmpty) { // TODO handle h1, h2, h3, h4, h5 return HeadingNode( - headingLevel!, inlineNodes(), debugHtmlNode: debugHtmlNode); + headingLevel!, inlineNodes(), debugHtmlNode: debugHtmlNode); } if (localName == 'blockquote' && classes.isEmpty) { @@ -488,9 +489,8 @@ List parseImplicitParagraphBlockContentList(dom.NodeList nodes final List currentParagraph = []; void consumeParagraph() { result.add(ParagraphNode( - wasImplicit: true, - nodes: - currentParagraph.map(parseInlineContent).toList(growable: false))); + wasImplicit: true, + nodes: currentParagraph.map(parseInlineContent).toList(growable: false))); currentParagraph.clear(); } diff --git a/lib/model/message_list.dart b/lib/model/message_list.dart index b2c9109d4a..6fb84a9837 100644 --- a/lib/model/message_list.dart +++ b/lib/model/message_list.dart @@ -57,8 +57,10 @@ class MessageListView extends ChangeNotifier { assert(messages.isEmpty); assert(contents.isEmpty); // TODO schedule all this in another isolate - final result = - await getMessages(store.connection, numBefore: 100, numAfter: 10); + final result = await getMessages(store.connection, + numBefore: 100, + numAfter: 10, + ); messages.addAll(result.messages); contents.addAll(_contentsOfMessages(result.messages)); _fetched = true; @@ -92,8 +94,7 @@ class MessageListView extends ChangeNotifier { notifyListeners(); } - static Iterable _contentsOfMessages( - Iterable messages) { + static Iterable _contentsOfMessages(Iterable messages) { // This will get more complicated to handle the ways that messages interact // with the display of neighboring messages: sender headings, // recipient headings, and date separators. diff --git a/lib/model/store.dart b/lib/model/store.dart index 3981975792..9534740b84 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -38,7 +38,7 @@ export 'database.dart' show Account, AccountsCompanion; /// we use outside of tests. abstract class GlobalStore extends ChangeNotifier { GlobalStore({required Map accounts}) - : _accounts = accounts; + : _accounts = accounts; /// A cache of the [Accounts] table in the underlying data store. final Map _accounts; @@ -148,14 +148,15 @@ class PerAccountStore extends ChangeNotifier { required this.account, required this.connection, required InitialSnapshot initialSnapshot, - }) : zulipVersion = initialSnapshot.zulipVersion, - users = Map.fromEntries(initialSnapshot.realmUsers - .followedBy(initialSnapshot.realmNonActiveUsers) - .followedBy(initialSnapshot.crossRealmBots) - .map((user) => MapEntry(user.userId, user))), - subscriptions = Map.fromEntries(initialSnapshot.subscriptions.map( - (subscription) => MapEntry(subscription.streamId, subscription))), - maxFileUploadSizeMib = initialSnapshot.maxFileUploadSizeMib; + }) : zulipVersion = initialSnapshot.zulipVersion, + users = Map.fromEntries( + initialSnapshot.realmUsers + .followedBy(initialSnapshot.realmNonActiveUsers) + .followedBy(initialSnapshot.crossRealmBots) + .map((user) => MapEntry(user.userId, user))), + subscriptions = Map.fromEntries(initialSnapshot.subscriptions.map( + (subscription) => MapEntry(subscription.streamId, subscription))), + maxFileUploadSizeMib = initialSnapshot.maxFileUploadSizeMib; final Account account; final ApiConnection connection; @@ -324,13 +325,13 @@ class LivePerAccountStore extends PerAccountStore { required super.account, required super.connection, required super.initialSnapshot, - }) : queueId = initialSnapshot.queueId ?? (() { - // The queueId is optional in the type, but should only be missing in the - // case of unauthenticated access to a web-public realm. We authenticated. - throw Exception("bad initial snapshot: missing queueId"); - })(), - lastEventId = initialSnapshot.lastEventId, - super.fromInitialSnapshot(); + }) : queueId = initialSnapshot.queueId ?? (() { + // The queueId is optional in the type, but should only be missing in the + // case of unauthenticated access to a web-public realm. We authenticated. + throw Exception("bad initial snapshot: missing queueId"); + })(), + lastEventId = initialSnapshot.lastEventId, + super.fromInitialSnapshot(); /// Load the user's data from the server, and start an event queue going. /// @@ -360,7 +361,7 @@ class LivePerAccountStore extends PerAccountStore { void poll() async { while (true) { final result = await getEvents(connection, - queueId: queueId, lastEventId: lastEventId); + queueId: queueId, lastEventId: lastEventId); // TODO handle errors on get-events; retry with backoff // TODO abort long-poll and close ApiConnection on [dispose] final events = result.events; diff --git a/lib/widgets/action_sheet.dart b/lib/widgets/action_sheet.dart index 740684a193..2fb9a27356 100644 --- a/lib/widgets/action_sheet.dart +++ b/lib/widgets/action_sheet.dart @@ -8,32 +8,29 @@ void showMessageActionSheet({required BuildContext context, required Message mes showDraggableScrollableModalBottomSheet( context: context, builder: (BuildContext context) { - return Column( - children: [ - MenuItemButton( - leadingIcon: Icon(Icons.adaptive.share), - onPressed: () async { - // Close the message action sheet; we're about to show the share - // sheet. (We could do this after the sharing Future settles, but - // on iOS I get impatient with how slowly our action sheet - // dismisses in that case.) - // TODO(#24): Fix iOS bug where this call causes the keyboard to - // reopen (if it was open at the time of this - // `showMessageActionSheet` call) and cover a large part of the - // share sheet. - Navigator.of(context).pop(); + return Column(children: [ + MenuItemButton( + leadingIcon: Icon(Icons.adaptive.share), + onPressed: () async { + // Close the message action sheet; we're about to show the share + // sheet. (We could do this after the sharing Future settles, but + // on iOS I get impatient with how slowly our action sheet + // dismisses in that case.) + // TODO(#24): Fix iOS bug where this call causes the keyboard to + // reopen (if it was open at the time of this + // `showMessageActionSheet` call) and cover a large part of the + // share sheet. + Navigator.of(context).pop(); - // TODO: to support iPads, we're asked to give a - // `sharePositionOrigin` param, or risk crashing / hanging: - // https://pub.dev/packages/share_plus#ipad - // Perhaps a wart in the API; discussion: - // https://github.com/zulip/zulip-flutter/pull/12#discussion_r1130146231 - // TODO: Share raw Markdown, not HTML - await Share.shareWithResult(message.content); - }, - child: const Text('Share'), - ), - ] - ); + // TODO: to support iPads, we're asked to give a + // `sharePositionOrigin` param, or risk crashing / hanging: + // https://pub.dev/packages/share_plus#ipad + // Perhaps a wart in the API; discussion: + // https://github.com/zulip/zulip-flutter/pull/12#discussion_r1130146231 + // TODO: Share raw Markdown, not HTML + await Share.shareWithResult(message.content); + }, + child: const Text('Share')), + ]); }); } diff --git a/lib/widgets/compose_box.dart b/lib/widgets/compose_box.dart index d3a5505b8b..dedefa1795 100644 --- a/lib/widgets/compose_box.dart +++ b/lib/widgets/compose_box.dart @@ -197,7 +197,7 @@ class _StreamContentInputState extends State<_StreamContentInput> { minHeight: _sendButtonSize - 2 * _inputVerticalPadding, // TODO constrain this adaptively (i.e. not hard-coded 200) - maxHeight: 200 + maxHeight: 200, ), child: TextField( controller: widget.controller, @@ -207,9 +207,7 @@ class _StreamContentInputState extends State<_StreamContentInput> { hintText: "Message #test here > $_topicTextNormalized", ), maxLines: null, - ), - ), - ); + ))); } } @@ -252,7 +250,9 @@ Future _uploadFiles({ context: context, title: 'File(s) too large', message: - '${tooLargeFiles.length} file(s) are larger than the server\'s limit of ${store.maxFileUploadSizeMib} MiB and will not be uploaded:\n\n$listMessage'); + '${tooLargeFiles.length} file(s) are larger than the server\'s limit' + ' of ${store.maxFileUploadSizeMib} MiB and will not be uploaded:' + '\n\n$listMessage'); } final List<(int, _File)> uploadsInProgress = []; @@ -322,7 +322,10 @@ abstract class _AttachUploadsButton extends StatelessWidget { @override Widget build(BuildContext context) { - return IconButton(icon: Icon(icon), tooltip: tooltip, onPressed: () => _handlePress(context)); + return IconButton( + icon: Icon(icon), + tooltip: tooltip, + onPressed: () => _handlePress(context)); } } @@ -502,9 +505,9 @@ class _StreamSendButtonState extends State<_StreamSendButton> { ]; return showErrorDialog( - context: context, - title: 'Message not sent', - message: validationErrorMessages.join('\n\n')); + context: context, + title: 'Message not sent', + message: validationErrorMessages.join('\n\n')); } void _handleSendPressed(BuildContext context) { @@ -530,13 +533,13 @@ class _StreamSendButtonState extends State<_StreamSendButton> { // Copy FilledButton defaults (_FilledButtonDefaultsM3.backgroundColor) final backgroundColor = disabled - ? colorScheme.onSurface.withOpacity(0.12) - : colorScheme.primary; + ? colorScheme.onSurface.withOpacity(0.12) + : colorScheme.primary; // Copy FilledButton defaults (_FilledButtonDefaultsM3.foregroundColor) final foregroundColor = disabled - ? colorScheme.onSurface.withOpacity(0.38) - : colorScheme.onPrimary; + ? colorScheme.onSurface.withOpacity(0.38) + : colorScheme.onPrimary; return Ink( decoration: BoxDecoration( @@ -551,9 +554,7 @@ class _StreamSendButtonState extends State<_StreamSendButton> { color: foregroundColor, icon: const Icon(Icons.send), - onPressed: () => _handleSendPressed(context), - ), - ); + onPressed: () => _handleSendPressed(context))); } } @@ -588,13 +589,10 @@ class _StreamComposeBoxState extends State { // Both [contentPadding] and [isDense] combine to make the layout compact. isDense: true, contentPadding: const EdgeInsets.symmetric( - horizontal: 12.0, vertical: _inputVerticalPadding), - + horizontal: 12.0, vertical: _inputVerticalPadding), border: const OutlineInputBorder( borderRadius: BorderRadius.all(Radius.circular(4.0)), - borderSide: BorderSide.none, - ), - + borderSide: BorderSide.none), filled: true, fillColor: colorScheme.surface, ), @@ -609,36 +607,33 @@ class _StreamComposeBoxState extends State { return Material( color: colorScheme.surfaceVariant, child: SafeArea( - minimum: const EdgeInsets.fromLTRB(8, 0, 8, 8), - child: Padding( - padding: const EdgeInsets.only(top: 8.0), - child: Column( - children: [ - Row(crossAxisAlignment: CrossAxisAlignment.end, children: [ - Expanded( - child: Theme( - data: inputThemeData, - child: Column( - children: [ - topicInput, - const SizedBox(height: 8), - _StreamContentInput( - topicController: _topicController, - controller: _contentController, - focusNode: _contentFocusNode), - ]))), - const SizedBox(width: 8), - _StreamSendButton(topicController: _topicController, contentController: _contentController), - ]), - Theme( - data: themeData.copyWith( - iconTheme: themeData.iconTheme.copyWith(color: colorScheme.onSurfaceVariant)), - child: Row( - children: [ - _AttachFileButton(contentController: _contentController, contentFocusNode: _contentFocusNode), - _AttachMediaButton(contentController: _contentController, contentFocusNode: _contentFocusNode), - _AttachFromCameraButton(contentController: _contentController, contentFocusNode: _contentFocusNode), - ])), - ])))); + minimum: const EdgeInsets.fromLTRB(8, 0, 8, 8), + child: Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Column(children: [ + Row(crossAxisAlignment: CrossAxisAlignment.end, children: [ + Expanded( + child: Theme( + data: inputThemeData, + child: Column(children: [ + topicInput, + const SizedBox(height: 8), + _StreamContentInput( + topicController: _topicController, + controller: _contentController, + focusNode: _contentFocusNode), + ]))), + const SizedBox(width: 8), + _StreamSendButton(topicController: _topicController, contentController: _contentController), + ]), + Theme( + data: themeData.copyWith( + iconTheme: themeData.iconTheme.copyWith(color: colorScheme.onSurfaceVariant)), + child: Row(children: [ + _AttachFileButton(contentController: _contentController, contentFocusNode: _contentFocusNode), + _AttachMediaButton(contentController: _contentController, contentFocusNode: _contentFocusNode), + _AttachFromCameraButton(contentController: _contentController, contentFocusNode: _contentFocusNode), + ])), + ])))); } } diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart index c41fafa5b6..5bd24b728f 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -84,22 +84,21 @@ class BlockContentNodeWidget extends StatelessWidget { // TODO h1, h2, h3, h4, h5 -- same as h6 except font size assert(node.level == HeadingLevel.h6); return Padding( - padding: const EdgeInsets.only(top: 15, bottom: 5), - child: Text.rich(TextSpan( - style: const TextStyle(fontWeight: FontWeight.w600, height: 1.4), - children: _buildInlineList(node.nodes)))); + padding: const EdgeInsets.only(top: 15, bottom: 5), + child: Text.rich(TextSpan( + style: const TextStyle(fontWeight: FontWeight.w600, height: 1.4), + children: _buildInlineList(node.nodes)))); } else if (node is QuotationNode) { return Padding( - padding: const EdgeInsets.only(left: 10), - child: Container( - padding: const EdgeInsets.only(left: 5), - decoration: BoxDecoration( - border: Border( - left: BorderSide( - width: 5, - color: const HSLColor.fromAHSL(1, 0, 0, 0.87) - .toColor()))), - child: BlockContentList(nodes: node.nodes))); + padding: const EdgeInsets.only(left: 10), + child: Container( + padding: const EdgeInsets.only(left: 5), + decoration: BoxDecoration( + border: Border( + left: BorderSide( + width: 5, + color: const HSLColor.fromAHSL(1, 0, 0, 0.87).toColor()))), + child: BlockContentList(nodes: node.nodes))); } else if (node is CodeBlockNode) { return CodeBlock(node: node); } else if (node is ImageNode) { @@ -133,8 +132,8 @@ class Paragraph extends StatelessWidget { // For a non-empty paragraph, though — and where there was a `p` element // for the Zulip CSS to apply to — the margins are real. return Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: text); + padding: const EdgeInsets.symmetric(vertical: 4), + child: text); } } @@ -164,8 +163,8 @@ class ListNodeWidget extends StatelessWidget { return ListItemWidget(marker: marker, nodes: item); }); return Padding( - padding: const EdgeInsets.only(top: 2, bottom: 5), - child: Column(children: items)); + padding: const EdgeInsets.only(top: 2, bottom: 5), + child: Column(children: items)); } } @@ -178,16 +177,16 @@ class ListItemWidget extends StatelessWidget { @override Widget build(BuildContext context) { return Row( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.baseline, - textBaseline: TextBaseline.alphabetic, - children: [ - SizedBox( - width: 20, // TODO handle long numbers in
    , like https://github.com/zulip/zulip/pull/25063 - child: Align( - alignment: AlignmentDirectional.topEnd, child: Text(marker))), - Expanded(child: BlockContentList(nodes: nodes)), - ]); + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + SizedBox( + width: 20, // TODO handle long numbers in
      , like https://github.com/zulip/zulip/pull/25063 + child: Align( + alignment: AlignmentDirectional.topEnd, child: Text(marker))), + Expanded(child: BlockContentList(nodes: nodes)), + ]); } } @@ -213,24 +212,24 @@ class MessageImage extends StatelessWidget { context: context, message: message, src: resolvedSrc)); }, child: Align( - alignment: Alignment.centerLeft, - child: Padding( - // TODO clean up this padding by imitating web less precisely; - // in particular, avoid adding loose whitespace at end of message. - // The corresponding element on web has a 5px two-sided margin… - // and then a 1px transparent border all around. - padding: const EdgeInsets.fromLTRB(1, 1, 6, 6), - child: Container( - height: 100, - width: 150, - alignment: Alignment.center, - color: const Color.fromRGBO(0, 0, 0, 0.03), - child: LightboxHero( - message: message, - src: resolvedSrc, - child: RealmContentNetworkImage( - resolvedSrc, - filterQuality: FilterQuality.medium)))))); + alignment: Alignment.centerLeft, + child: Padding( + // TODO clean up this padding by imitating web less precisely; + // in particular, avoid adding loose whitespace at end of message. + // The corresponding element on web has a 5px two-sided margin… + // and then a 1px transparent border all around. + padding: const EdgeInsets.fromLTRB(1, 1, 6, 6), + child: Container( + height: 100, + width: 150, + alignment: Alignment.center, + color: const Color.fromRGBO(0, 0, 0, 0.03), + child: LightboxHero( + message: message, + src: resolvedSrc, + child: RealmContentNetworkImage( + resolvedSrc, + filterQuality: FilterQuality.medium)))))); } } @@ -244,28 +243,28 @@ class CodeBlock extends StatelessWidget { final text = node.text; return Container( - padding: const EdgeInsets.fromLTRB(7, 5, 7, 3), - decoration: BoxDecoration( - color: Colors.white, - border: Border.all( - width: 1, - color: const HSLColor.fromAHSL(0.15, 0, 0, 0).toColor())), - child: SingleChildScrollViewWithScrollbar( - scrollDirection: Axis.horizontal, - child: Text(text, style: _kCodeStyle))); + padding: const EdgeInsets.fromLTRB(7, 5, 7, 3), + decoration: BoxDecoration( + color: Colors.white, + border: Border.all( + width: 1, + color: const HSLColor.fromAHSL(0.15, 0, 0, 0).toColor())), + child: SingleChildScrollViewWithScrollbar( + scrollDirection: Axis.horizontal, + child: Text(text, style: _kCodeStyle))); } } class SingleChildScrollViewWithScrollbar extends StatefulWidget { const SingleChildScrollViewWithScrollbar( - {super.key, required this.scrollDirection, required this.child}); + {super.key, required this.scrollDirection, required this.child}); final Axis scrollDirection; final Widget child; @override State createState() => - _SingleChildScrollViewWithScrollbarState(); + _SingleChildScrollViewWithScrollbarState(); } class _SingleChildScrollViewWithScrollbarState @@ -276,9 +275,10 @@ class _SingleChildScrollViewWithScrollbarState Widget build(BuildContext context) { return Scrollbar( controller: controller, - child: SingleChildScrollView( - controller: controller, - scrollDirection: widget.scrollDirection, child: widget.child)); + child: SingleChildScrollView( + controller: controller, + scrollDirection: widget.scrollDirection, + child: widget.child)); } } @@ -287,11 +287,11 @@ class _SingleChildScrollViewWithScrollbarState // List _buildInlineList(List nodes) => - List.of(nodes.map(_buildInlineNode)); + List.of(nodes.map(_buildInlineNode)); InlineSpan _buildInlineNode(InlineContentNode node) { InlineSpan styled(List nodes, TextStyle style) => - TextSpan(children: _buildInlineList(nodes), style: style); + TextSpan(children: _buildInlineList(nodes), style: style); if (node is TextNode) { return TextSpan(text: node.text); @@ -308,18 +308,16 @@ InlineSpan _buildInlineNode(InlineContentNode node) { } else if (node is LinkNode) { // TODO make link touchable return styled(node.nodes, - TextStyle(color: const HSLColor.fromAHSL(1, 200, 1, 0.4).toColor())); + TextStyle(color: const HSLColor.fromAHSL(1, 200, 1, 0.4).toColor())); } else if (node is UserMentionNode) { - return WidgetSpan( - alignment: PlaceholderAlignment.middle, child: UserMention(node: node)); + return WidgetSpan(alignment: PlaceholderAlignment.middle, + child: UserMention(node: node)); } else if (node is UnicodeEmojiNode) { - return WidgetSpan( - alignment: PlaceholderAlignment.middle, - child: MessageUnicodeEmoji(node: node)); + return WidgetSpan(alignment: PlaceholderAlignment.middle, + child: MessageUnicodeEmoji(node: node)); } else if (node is ImageEmojiNode) { - return WidgetSpan( - alignment: PlaceholderAlignment.middle, - child: MessageImageEmoji(node: node)); + return WidgetSpan(alignment: PlaceholderAlignment.middle, + child: MessageImageEmoji(node: node)); } else if (node is UnimplementedInlineContentNode) { return _errorUnimplemented(node); } else { @@ -354,13 +352,13 @@ InlineSpan inlineCode(InlineCodeNode node) { // Use a light gray background, instead of a border. return TextSpan( - style: const TextStyle( - backgroundColor: Color(0xffeeeeee), - fontSize: 0.825 * kBaseFontSize, - fontFamily: "Source Code Pro", // TODO supply font - fontFamilyFallback: ["monospace"], - ), - children: _buildInlineList(node.nodes)); + style: const TextStyle( + backgroundColor: Color(0xffeeeeee), + fontSize: 0.825 * kBaseFontSize, + fontFamily: "Source Code Pro", // TODO supply font + fontFamilyFallback: ["monospace"], + ), + children: _buildInlineList(node.nodes)); // Another fun solution -- we can in fact have a border! Like so: // TextStyle( @@ -410,19 +408,19 @@ class UserMention extends StatelessWidget { @override Widget build(BuildContext context) { return Container( - decoration: _kDecoration, - padding: const EdgeInsets.symmetric(horizontal: 0.2 * kBaseFontSize), - child: Text.rich(TextSpan(children: _buildInlineList(node.nodes)))); + decoration: _kDecoration, + padding: const EdgeInsets.symmetric(horizontal: 0.2 * kBaseFontSize), + child: Text.rich(TextSpan(children: _buildInlineList(node.nodes)))); } static get _kDecoration => BoxDecoration( - gradient: const LinearGradient( - colors: [Color.fromRGBO(0, 0, 0, 0.1), Color.fromRGBO(0, 0, 0, 0)], - begin: Alignment.topCenter, - end: Alignment.bottomCenter), - border: Border.all( - color: const Color.fromRGBO(0xcc, 0xcc, 0xcc, 1), width: 1), - borderRadius: const BorderRadius.all(Radius.circular(3))); + gradient: const LinearGradient( + colors: [Color.fromRGBO(0, 0, 0, 0.1), Color.fromRGBO(0, 0, 0, 0)], + begin: Alignment.topCenter, + end: Alignment.bottomCenter), + border: Border.all( + color: const Color.fromRGBO(0xcc, 0xcc, 0xcc, 1), width: 1), + borderRadius: const BorderRadius.all(Radius.circular(3))); // This is a more literal translation of Zulip web's CSS. // But it turns out CSS `box-shadow` has a quirk we rely on there: @@ -431,18 +429,18 @@ class UserMention extends StatelessWidget { // which is after all more logical from the "shadow" metaphor. // // static const _kDecoration = ShapeDecoration( -// gradient: LinearGradient( -// colors: [Color.fromRGBO(0, 0, 0, 0.1), Color.fromRGBO(0, 0, 0, 0)], -// begin: Alignment.topCenter, -// end: Alignment.bottomCenter), -// shadows: [ -// BoxShadow( -// spreadRadius: 1, -// blurStyle: BlurStyle.outer, -// color: Color.fromRGBO(0xcc, 0xcc, 0xcc, 1)) -// ], -// shape: RoundedRectangleBorder( -// borderRadius: BorderRadius.all(Radius.circular(3)))); +// gradient: LinearGradient( +// colors: [Color.fromRGBO(0, 0, 0, 0.1), Color.fromRGBO(0, 0, 0, 0)], +// begin: Alignment.topCenter, +// end: Alignment.bottomCenter), +// shadows: [ +// BoxShadow( +// spreadRadius: 1, +// blurStyle: BlurStyle.outer, +// color: Color.fromRGBO(0xcc, 0xcc, 0xcc, 1)), +// ], +// shape: RoundedRectangleBorder( +// borderRadius: BorderRadius.all(Radius.circular(3)))); } class MessageUnicodeEmoji extends StatelessWidget { @@ -455,10 +453,10 @@ class MessageUnicodeEmoji extends StatelessWidget { // TODO get spritesheet and show actual emoji glyph final text = node.text; return Container( - padding: const EdgeInsets.all(2), - decoration: BoxDecoration( - color: Colors.white, border: Border.all(color: Colors.purple)), - child: Text(text)); + padding: const EdgeInsets.all(2), + decoration: BoxDecoration( + color: Colors.white, border: Border.all(color: Colors.purple)), + child: Text(text)); } } @@ -475,21 +473,21 @@ class MessageImageEmoji extends StatelessWidget { const size = 20.0; return Stack( - alignment: Alignment.center, - clipBehavior: Clip.none, - children: [ - const SizedBox(width: size, height: kBaseFontSize), - Positioned( - // Web's css makes this seem like it should be -0.5, but that looks - // too low. - top: -1.5, - child: RealmContentNetworkImage( - resolvedSrc, - filterQuality: FilterQuality.medium, - width: size, - height: size, - )), - ]); + alignment: Alignment.center, + clipBehavior: Clip.none, + children: [ + const SizedBox(width: size, height: kBaseFontSize), + Positioned( + // Web's css makes this seem like it should be -0.5, but that looks + // too low. + top: -1.5, + child: RealmContentNetworkImage( + resolvedSrc, + filterQuality: FilterQuality.medium, + width: size, + height: size, + )), + ]); } } @@ -634,8 +632,8 @@ InlineSpan _errorUnimplemented(UnimplementedNode node) { ]); } else { return TextSpan( - text: "(unimplemented: DOM node type ${htmlNode.nodeType})", - style: errorStyle); + text: "(unimplemented: DOM node type ${htmlNode.nodeType})", + style: errorStyle); } } diff --git a/lib/widgets/dialog.dart b/lib/widgets/dialog.dart index d0c363b2bb..a9bc438af2 100644 --- a/lib/widgets/dialog.dart +++ b/lib/widgets/dialog.dart @@ -15,17 +15,21 @@ Widget _dialogActionText(String text) { } // TODO(i18n): title, message, and action-button text -void showErrorDialog({required BuildContext context, required String title, String? message}) { +void showErrorDialog({ + required BuildContext context, + required String title, + String? message, +}) { showDialog( - context: context, - builder: (BuildContext context) => AlertDialog( - title: Text(title), - content: message != null ? SingleChildScrollView(child: Text(message)) : null, - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: _dialogActionText('OK')), - ])); + context: context, + builder: (BuildContext context) => AlertDialog( + title: Text(title), + content: message != null ? SingleChildScrollView(child: Text(message)) : null, + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: _dialogActionText('OK')), + ])); } void showSuggestedActionDialog({ @@ -35,15 +39,17 @@ void showSuggestedActionDialog({ required String? actionButtonText, required VoidCallback onActionButtonPress, }) { - showDialog(context: context, builder: (BuildContext context) => AlertDialog( - title: Text(title), - content: SingleChildScrollView(child: Text(message)), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: _dialogActionText('Cancel')), - TextButton( - onPressed: onActionButtonPress, - child: _dialogActionText(actionButtonText ?? 'Continue')), - ])); + showDialog( + context: context, + builder: (BuildContext context) => AlertDialog( + title: Text(title), + content: SingleChildScrollView(child: Text(message)), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: _dialogActionText('Cancel')), + TextButton( + onPressed: onActionButtonPress, + child: _dialogActionText(actionButtonText ?? 'Continue')), + ])); } diff --git a/lib/widgets/draggable_scrollable_modal_bottom_sheet.dart b/lib/widgets/draggable_scrollable_modal_bottom_sheet.dart index 6e3237407b..e8e390d8fe 100644 --- a/lib/widgets/draggable_scrollable_modal_bottom_sheet.dart +++ b/lib/widgets/draggable_scrollable_modal_bottom_sheet.dart @@ -8,42 +8,39 @@ class _DraggableScrollableLayer extends StatelessWidget { @override Widget build(BuildContext context) { return DraggableScrollableSheet( - // Match `initial…` to `min…` so that a slight drag downward dismisses - // the sheet instead of just resizing it. Making them equal gives a - // buggy experience for some reason - // ( https://github.com/zulip/zulip-flutter/pull/12#discussion_r1116423455 ) - // so we work around by make `initial…` a bit bigger. - minChildSize: 0.25, - initialChildSize: 0.26, + // Match `initial…` to `min…` so that a slight drag downward dismisses + // the sheet instead of just resizing it. Making them equal gives a + // buggy experience for some reason + // ( https://github.com/zulip/zulip-flutter/pull/12#discussion_r1116423455 ) + // so we work around by make `initial…` a bit bigger. + minChildSize: 0.25, + initialChildSize: 0.26, - // With `expand: true`, the bottom sheet would then start out occupying - // the whole screen, as if `initialChildSize` was 1.0. That doesn't seem - // like what the docs call for. Maybe a bug. Or maybe it's somehow - // related to the `Stack`? - expand: false, + // With `expand: true`, the bottom sheet would then start out occupying + // the whole screen, as if `initialChildSize` was 1.0. That doesn't seem + // like what the docs call for. Maybe a bug. Or maybe it's somehow + // related to the `Stack`? + expand: false, - builder: (BuildContext context, ScrollController scrollController) { - return SingleChildScrollView( - // Prevent overscroll animation on swipe down; it looks - // sloppy when you're swiping to dismiss the sheet. - physics: const ClampingScrollPhysics(), + builder: (BuildContext context, ScrollController scrollController) { + return SingleChildScrollView( + // Prevent overscroll animation on swipe down; it looks + // sloppy when you're swiping to dismiss the sheet. + physics: const ClampingScrollPhysics(), - controller: scrollController, + controller: scrollController, - child: Padding( - // Avoid the drag handle. See comment on - // _DragHandleLayer's SizedBox.height. - padding: const EdgeInsets.only(top: kMinInteractiveDimension), + child: Padding( + // Avoid the drag handle. See comment on + // _DragHandleLayer's SizedBox.height. + padding: const EdgeInsets.only(top: kMinInteractiveDimension), - // Extend DraggableScrollableSheet to full width so the whole - // sheet responds to drag/scroll uniformly. - child: FractionallySizedBox( - widthFactor: 1.0, - child: Builder(builder: builder), - ), - ), - ); - }); + // Extend DraggableScrollableSheet to full width so the whole + // sheet responds to drag/scroll uniformly. + child: FractionallySizedBox( + widthFactor: 1.0, + child: Builder(builder: builder)))); + }); } } @@ -76,9 +73,7 @@ class _DragHandleLayer extends StatelessWidget { // https://m3.material.io/components/bottom-sheets/specs#7c093473-d9e1-48f3-9659-b75519c2a29d height: 4, width: 32, - child: ColoredBox(color: colorScheme.onSurfaceVariant.withOpacity(0.40)), - ), - ))); + child: ColoredBox(color: colorScheme.onSurfaceVariant.withOpacity(0.40)))))); } } @@ -118,7 +113,6 @@ Future showDraggableScrollableModalBottomSheet({ children: [ _DraggableScrollableLayer(builder: builder), _DragHandleLayer(), - ], - ); + ]); }); } diff --git a/lib/widgets/lightbox.dart b/lib/widgets/lightbox.dart index cf9eadbba3..e68e15a64d 100644 --- a/lib/widgets/lightbox.dart +++ b/lib/widgets/lightbox.dart @@ -59,8 +59,7 @@ class LightboxHero extends StatelessWidget { // For a RealmContentNetworkImage shown during flight. return PerAccountStoreWidget(accountId: accountId, child: child); }, - child: child, - ); + child: child); } } @@ -137,9 +136,9 @@ class _LightboxPageState extends State<_LightboxPage> { if (_headerFooterVisible) { // TODO(#45): Format with e.g. "Yesterday at 4:47 PM" final timestampText = DateFormat - .yMMMd(/* TODO(i18n): Pass selected language here, I think? */) - .add_Hms() - .format(DateTime.fromMillisecondsSinceEpoch(widget.message.timestamp * 1000)); + .yMMMd(/* TODO(i18n): Pass selected language here, I think? */) + .add_Hms() + .format(DateTime.fromMillisecondsSinceEpoch(widget.message.timestamp * 1000)); appBar = AppBar( centerTitle: false, @@ -148,31 +147,29 @@ class _LightboxPageState extends State<_LightboxPage> { // TODO(#41): Show message author's avatar title: RichText( - text: TextSpan( - children: [ - TextSpan( - text: '${widget.message.senderFullName}\n', - - // Restate default - style: themeData.textTheme.titleLarge!.copyWith(color: appBarForegroundColor)), - TextSpan( - text: timestampText, - - // Make smaller, like a subtitle - style: themeData.textTheme.titleSmall!.copyWith(color: appBarForegroundColor)), - ]))); + text: TextSpan(children: [ + TextSpan( + text: '${widget.message.senderFullName}\n', + + // Restate default + style: themeData.textTheme.titleLarge!.copyWith(color: appBarForegroundColor)), + TextSpan( + text: timestampText, + + // Make smaller, like a subtitle + style: themeData.textTheme.titleSmall!.copyWith(color: appBarForegroundColor)), + ]))); } Widget? bottomAppBar; if (_headerFooterVisible) { bottomAppBar = BottomAppBar( color: appBarBackgroundColor, - child: Row( - children: [ - _CopyLinkButton(url: widget.src), - // TODO(#43): Share image - // TODO(#42): Download image - ])); + child: Row(children: [ + _CopyLinkButton(url: widget.src), + // TODO(#43): Share image + // TODO(#42): Download image + ])); } return Theme( diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index a164459f90..d7e3e3d06f 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -13,7 +13,7 @@ import 'sticky_header.dart'; import 'store.dart'; class MessageList extends StatefulWidget { - const MessageList({Key? key}) : super(key: key); + const MessageList({super.key}); @override State createState() => _MessageListState(); @@ -62,49 +62,48 @@ class _MessageListState extends State { if (!model!.fetched) return const Center(child: CircularProgressIndicator()); return DefaultTextStyle( - // TODO figure out text color -- web is supposedly hsl(0deg 0% 20%), - // but seems much darker than that - style: const TextStyle(color: Color.fromRGBO(0, 0, 0, 1)), - child: ColoredBox( - color: Colors.white, - - // Pad the left and right insets, for small devices in landscape. - child: SafeArea( - // Keep some padding when there are no horizontal insets, - // which is usual in portrait mode. - minimum: const EdgeInsets.symmetric(horizontal: 8), - child: Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 760), - child: _buildListView(context)))))); + // TODO figure out text color -- web is supposedly hsl(0deg 0% 20%), + // but seems much darker than that + style: const TextStyle(color: Color.fromRGBO(0, 0, 0, 1)), + child: ColoredBox( + color: Colors.white, + // Pad the left and right insets, for small devices in landscape. + child: SafeArea( + // Keep some padding when there are no horizontal insets, + // which is usual in portrait mode. + minimum: const EdgeInsets.symmetric(horizontal: 8), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 760), + child: _buildListView(context)))))); } Widget _buildListView(context) { final length = model!.messages.length; assert(model!.contents.length == length); return StickyHeaderListView.builder( - // TODO: Offer `ScrollViewKeyboardDismissBehavior.interactive` (or - // similar) if that is ever offered: - // https://github.com/flutter/flutter/issues/57609#issuecomment-1355340849 - keyboardDismissBehavior: Platform.isIOS - // This seems to offer the only built-in way to close the keyboard - // on iOS. It's not ideal; see TODO above. - ? ScrollViewKeyboardDismissBehavior.onDrag - // The Android keyboard seems to have a built-in close button. - : ScrollViewKeyboardDismissBehavior.manual, - - itemCount: length, - // Setting reverse: true means the scroll starts at the bottom. - // Flipping the indexes (in itemBuilder) means the start/bottom - // has the latest messages. - // This works great when we want to start from the latest. - // TODO handle scroll starting at first unread, or link anchor - // TODO on new message when scrolled up, anchor scroll to what's in view - reverse: true, - itemBuilder: (context, i) => MessageItem( - trailing: i == 0 ? const SizedBox(height: 8) : const SizedBox(height: 11), - message: model!.messages[length - 1 - i], - content: model!.contents[length - 1 - i])); + // TODO: Offer `ScrollViewKeyboardDismissBehavior.interactive` (or + // similar) if that is ever offered: + // https://github.com/flutter/flutter/issues/57609#issuecomment-1355340849 + keyboardDismissBehavior: Platform.isIOS + // This seems to offer the only built-in way to close the keyboard + // on iOS. It's not ideal; see TODO above. + ? ScrollViewKeyboardDismissBehavior.onDrag + // The Android keyboard seems to have a built-in close button. + : ScrollViewKeyboardDismissBehavior.manual, + + itemCount: length, + // Setting reverse: true means the scroll starts at the bottom. + // Flipping the indexes (in itemBuilder) means the start/bottom + // has the latest messages. + // This works great when we want to start from the latest. + // TODO handle scroll starting at first unread, or link anchor + // TODO on new message when scrolled up, anchor scroll to what's in view + reverse: true, + itemBuilder: (context, i) => MessageItem( + trailing: i == 0 ? const SizedBox(height: 8) : const SizedBox(height: 11), + message: model!.messages[length - 1 - i], + content: model!.contents[length - 1 - i])); } } @@ -135,7 +134,7 @@ class MessageItem extends StatelessWidget { highlightBorderColor = colorForStream(subscription); restBorderColor = _kStreamMessageBorderColor; recipientHeader = StreamTopicRecipientHeader( - message: msg, streamColor: highlightBorderColor); + message: msg, streamColor: highlightBorderColor); } else if (message is PmMessage) { final msg = (message as PmMessage); highlightBorderColor = _kPmRecipientHeaderColor; @@ -150,20 +149,20 @@ class MessageItem extends StatelessWidget { final recipientBorder = BorderSide(color: highlightBorderColor, width: 3); final restBorder = BorderSide(color: restBorderColor, width: 1); var borderDecoration = ShapeDecoration( - // Web actually uses, for stream messages, a slightly lighter border at - // right than at bottom and in the recipient header: black 10% alpha, - // vs. 88% lightness. Assume that's an accident. - shape: Border( - left: recipientBorder, bottom: restBorder, right: restBorder)); + // Web actually uses, for stream messages, a slightly lighter border at + // right than at bottom and in the recipient header: black 10% alpha, + // vs. 88% lightness. Assume that's an accident. + shape: Border( + left: recipientBorder, bottom: restBorder, right: restBorder)); return StickyHeader( - header: recipientHeader, - content: Column(children: [ - DecoratedBox( - decoration: borderDecoration, - child: MessageWithSender(message: message, content: content)), - if (trailing != null) trailing!, - ])); + header: recipientHeader, + content: Column(children: [ + DecoratedBox( + decoration: borderDecoration, + child: MessageWithSender(message: message, content: content)), + if (trailing != null) trailing!, + ])); // Web handles the left-side recipient marker in a funky way: // box-shadow: inset 3px 0px 0px -1px #c2726a, -1px 0px 0px 0px #c2726a; @@ -173,11 +172,11 @@ class MessageItem extends StatelessWidget { // At attempt at a literal translation might look like this: // // DecoratedBox( - // decoration: ShapeDecoration(shadows: [ - // BoxShadow(offset: Offset(3, 0), spreadRadius: -1, color: highlightBorderColor), - // BoxShadow(offset: Offset(-1, 0), color: highlightBorderColor), - // ], shape: Border.fromBorderSide(BorderSide.none)), - // child: MessageWithSender(message: message)), + // decoration: ShapeDecoration(shadows: [ + // BoxShadow(offset: Offset(3, 0), spreadRadius: -1, color: highlightBorderColor), + // BoxShadow(offset: Offset(-1, 0), color: highlightBorderColor), + // ], shape: Border.fromBorderSide(BorderSide.none)), + // child: MessageWithSender(message: message)), // // But CSS `box-shadow` seems to not apply under the item itself, while // Flutter's BoxShadow does. @@ -193,7 +192,7 @@ Color colorForStream(Subscription? subscription) { class StreamTopicRecipientHeader extends StatelessWidget { const StreamTopicRecipientHeader( - {super.key, required this.message, required this.streamColor}); + {super.key, required this.message, required this.streamColor}); final StreamMessage message; final Color streamColor; @@ -203,36 +202,33 @@ class StreamTopicRecipientHeader extends StatelessWidget { final streamName = message.displayRecipient; // TODO get from stream data final topic = message.subject; final contrastingColor = - ThemeData.estimateBrightnessForColor(streamColor) == Brightness.dark - ? Colors.white - : Colors.black; + ThemeData.estimateBrightnessForColor(streamColor) == Brightness.dark + ? Colors.white + : Colors.black; return ColoredBox( - color: _kStreamMessageBorderColor, - child: Row(mainAxisAlignment: MainAxisAlignment.start, children: [ - // TODO: Long stream name will break layout; find a fix. - RecipientHeaderChevronContainer( - color: streamColor, - // TODO globe/lock icons for web-public and private streams - child: - Text(streamName, style: TextStyle(color: contrastingColor))), - Expanded( - child: Padding( - // Web has padding 9, 3, 3, 2 here; but 5px is the chevron. - padding: const EdgeInsets.fromLTRB(4, 3, 3, 2), - child: Text(topic, - // TODO: Give a way to see the whole topic (maybe a - // long-press interaction?) - overflow: TextOverflow.ellipsis, - style: const TextStyle(fontWeight: FontWeight.w600))), - ), - // TODO topic links? - // Then web also has edit/resolve/mute buttons. Skip those for mobile. - ])); + color: _kStreamMessageBorderColor, + child: Row(mainAxisAlignment: MainAxisAlignment.start, children: [ + // TODO: Long stream name will break layout; find a fix. + RecipientHeaderChevronContainer( + color: streamColor, + // TODO globe/lock icons for web-public and private streams + child: Text(streamName, style: TextStyle(color: contrastingColor))), + Expanded( + child: Padding( + // Web has padding 9, 3, 3, 2 here; but 5px is the chevron. + padding: const EdgeInsets.fromLTRB(4, 3, 3, 2), + child: Text(topic, + // TODO: Give a way to see the whole topic (maybe a + // long-press interaction?) + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontWeight: FontWeight.w600)))), + // TODO topic links? + // Then web also has edit/resolve/mute buttons. Skip those for mobile. + ])); } } -final _kStreamMessageBorderColor = - const HSLColor.fromAHSL(1, 0, 0, 0.88).toColor(); +final _kStreamMessageBorderColor = const HSLColor.fromAHSL(1, 0, 0, 0.88).toColor(); class PmRecipientHeader extends StatelessWidget { const PmRecipientHeader({super.key, required this.message}); @@ -242,11 +238,11 @@ class PmRecipientHeader extends StatelessWidget { @override Widget build(BuildContext context) { return Align( - alignment: Alignment.centerLeft, - child: RecipientHeaderChevronContainer( - color: _kPmRecipientHeaderColor, - child: const Text("Private message", // TODO PM recipient headers - style: TextStyle(color: Colors.white)))); + alignment: Alignment.centerLeft, + child: RecipientHeaderChevronContainer( + color: _kPmRecipientHeaderColor, + child: const Text("Private message", // TODO PM recipient headers + style: TextStyle(color: Colors.white)))); } } @@ -256,7 +252,7 @@ final _kPmRecipientHeaderColor = /// A widget with the distinctive chevron-tailed shape in Zulip recipient headers. class RecipientHeaderChevronContainer extends StatelessWidget { const RecipientHeaderChevronContainer( - {super.key, required this.color, required this.child}); + {super.key, required this.color, required this.child}); final Color color; final Widget child; @@ -265,21 +261,21 @@ class RecipientHeaderChevronContainer extends StatelessWidget { Widget build(BuildContext context) { const chevronLength = 5.0; const recipientBorderShape = BeveledRectangleBorder( - borderRadius: BorderRadius.only( - topRight: Radius.elliptical(chevronLength, double.infinity), - bottomRight: Radius.elliptical(chevronLength, double.infinity))); + borderRadius: BorderRadius.only( + topRight: Radius.elliptical(chevronLength, double.infinity), + bottomRight: Radius.elliptical(chevronLength, double.infinity))); return Container( - decoration: ShapeDecoration(color: color, shape: recipientBorderShape), - padding: const EdgeInsets.only(right: chevronLength), - child: Padding( - padding: const EdgeInsets.fromLTRB(6, 4, 6, 3), child: child)); + decoration: ShapeDecoration(color: color, shape: recipientBorderShape), + padding: const EdgeInsets.only(right: chevronLength), + child: Padding( + padding: const EdgeInsets.fromLTRB(6, 4, 6, 3), child: child)); } } /// A Zulip message, showing the sender's name and avatar. class MessageWithSender extends StatelessWidget { const MessageWithSender( - {super.key, required this.message, required this.content}); + {super.key, required this.message, required this.content}); final Message message; final ZulipContent content; @@ -289,51 +285,50 @@ class MessageWithSender extends StatelessWidget { final store = PerAccountStoreWidget.of(context); final avatarUrl = message.avatarUrl == null // TODO get from user data - ? null // TODO handle computing gravatars - : resolveUrl(message.avatarUrl!, store.account); + ? null // TODO handle computing gravatars + : resolveUrl(message.avatarUrl!, store.account); final avatar = (avatarUrl == null) - ? const SizedBox.shrink() - : RealmContentNetworkImage( - avatarUrl, - filterQuality: FilterQuality.medium, - ); + ? const SizedBox.shrink() + : RealmContentNetworkImage( + avatarUrl, + filterQuality: FilterQuality.medium, + ); final time = _kMessageTimestampFormat - .format(DateTime.fromMillisecondsSinceEpoch(1000 * message.timestamp)); + .format(DateTime.fromMillisecondsSinceEpoch(1000 * message.timestamp)); return GestureDetector( behavior: HitTestBehavior.translucent, onLongPress: () => showMessageActionSheet(context: context, message: message), // TODO clean up this layout, by less precisely imitating web child: Padding( - padding: const EdgeInsets.only(top: 2, bottom: 3, left: 8, right: 8), - child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Padding( - padding: const EdgeInsets.fromLTRB(3, 6, 11, 0), - child: Container( - clipBehavior: Clip.antiAlias, - decoration: const BoxDecoration( - borderRadius: BorderRadius.all(Radius.circular(4))), - width: 35, - height: 35, - child: avatar)), - 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)), - const SizedBox(height: 4), - MessageContent(message: message, content: content), - ])), - Container( - width: 80, - padding: const EdgeInsets.only(top: 4, right: 2), - alignment: Alignment.topRight, - child: Text(time, style: _kMessageTimestampStyle)) - ])), - ); + padding: const EdgeInsets.only(top: 2, bottom: 3, left: 8, right: 8), + child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Padding( + padding: const EdgeInsets.fromLTRB(3, 6, 11, 0), + child: Container( + clipBehavior: Clip.antiAlias, + decoration: const BoxDecoration( + borderRadius: BorderRadius.all(Radius.circular(4))), + width: 35, + height: 35, + child: avatar)), + 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)), + const SizedBox(height: 4), + MessageContent(message: message, content: content), + ])), + Container( + width: 80, + padding: const EdgeInsets.only(top: 4, right: 2), + alignment: Alignment.topRight, + child: Text(time, style: _kMessageTimestampStyle)), + ]))); } } @@ -342,6 +337,6 @@ final _kMessageTimestampFormat = DateFormat('h:mm aa', 'en_US'); // TODO this seems to come out lighter than on web final _kMessageTimestampStyle = TextStyle( - fontSize: 12, - fontWeight: FontWeight.w400, - color: const HSLColor.fromAHSL(0.4, 0, 0, 0.2).toColor()); + fontSize: 12, + fontWeight: FontWeight.w400, + color: const HSLColor.fromAHSL(0.4, 0, 0, 0.2).toColor()); diff --git a/lib/widgets/sticky_header.dart b/lib/widgets/sticky_header.dart index 548a47f336..5082591841 100644 --- a/lib/widgets/sticky_header.dart +++ b/lib/widgets/sticky_header.dart @@ -24,15 +24,15 @@ class StickyHeaderListView extends BoxScrollView { super.keyboardDismissBehavior, super.restorationId, super.clipBehavior, - }) : childrenDelegate = SliverChildListDelegate( - children, - addAutomaticKeepAlives: addAutomaticKeepAlives, - addRepaintBoundaries: addRepaintBoundaries, - addSemanticIndexes: addSemanticIndexes, - ), - super( - semanticChildCount: semanticChildCount ?? children.length, - ); + }) : childrenDelegate = SliverChildListDelegate( + children, + addAutomaticKeepAlives: addAutomaticKeepAlives, + addRepaintBoundaries: addRepaintBoundaries, + addSemanticIndexes: addSemanticIndexes, + ), + super( + semanticChildCount: semanticChildCount ?? children.length, + ); // Like ListView.builder, but with sticky headers. StickyHeaderListView.builder({ @@ -56,19 +56,19 @@ class StickyHeaderListView extends BoxScrollView { super.keyboardDismissBehavior, super.restorationId, super.clipBehavior, - }) : assert(itemCount == null || itemCount >= 0), - assert(semanticChildCount == null || semanticChildCount <= itemCount!), - childrenDelegate = SliverChildBuilderDelegate( - itemBuilder, - findChildIndexCallback: findChildIndexCallback, - childCount: itemCount, - addAutomaticKeepAlives: addAutomaticKeepAlives, - addRepaintBoundaries: addRepaintBoundaries, - addSemanticIndexes: addSemanticIndexes, - ), - super( - semanticChildCount: semanticChildCount ?? itemCount, - ); + }) : assert(itemCount == null || itemCount >= 0), + assert(semanticChildCount == null || semanticChildCount <= itemCount!), + childrenDelegate = SliverChildBuilderDelegate( + itemBuilder, + findChildIndexCallback: findChildIndexCallback, + childCount: itemCount, + addAutomaticKeepAlives: addAutomaticKeepAlives, + addRepaintBoundaries: addRepaintBoundaries, + addSemanticIndexes: addSemanticIndexes, + ), + super( + semanticChildCount: semanticChildCount ?? itemCount, + ); // Like ListView.separated, but with sticky headers. StickyHeaderListView.separated({ @@ -92,36 +92,36 @@ class StickyHeaderListView extends BoxScrollView { super.keyboardDismissBehavior, super.restorationId, super.clipBehavior, - }) : assert(itemCount >= 0), - childrenDelegate = SliverChildBuilderDelegate( - (BuildContext context, int index) { - final int itemIndex = index ~/ 2; - final Widget? widget; - if (index.isEven) { - widget = itemBuilder(context, itemIndex); - } else { - widget = separatorBuilder(context, itemIndex); - assert(() { - if (widget == null) { - throw FlutterError('separatorBuilder cannot return null.'); - } - return true; - }()); - } - return widget; - }, - findChildIndexCallback: findChildIndexCallback, - childCount: math.max(0, itemCount * 2 - 1), - addAutomaticKeepAlives: addAutomaticKeepAlives, - addRepaintBoundaries: addRepaintBoundaries, - addSemanticIndexes: addSemanticIndexes, - semanticIndexCallback: (Widget _, int index) { - return index.isEven ? index ~/ 2 : null; - }, - ), - super( - semanticChildCount: itemCount, - ); + }) : assert(itemCount >= 0), + childrenDelegate = SliverChildBuilderDelegate( + (BuildContext context, int index) { + final int itemIndex = index ~/ 2; + final Widget? widget; + if (index.isEven) { + widget = itemBuilder(context, itemIndex); + } else { + widget = separatorBuilder(context, itemIndex); + assert(() { + if (widget == null) { + throw FlutterError('separatorBuilder cannot return null.'); + } + return true; + }()); + } + return widget; + }, + findChildIndexCallback: findChildIndexCallback, + childCount: math.max(0, itemCount * 2 - 1), + addAutomaticKeepAlives: addAutomaticKeepAlives, + addRepaintBoundaries: addRepaintBoundaries, + addSemanticIndexes: addSemanticIndexes, + semanticIndexCallback: (Widget _, int index) { + return index.isEven ? index ~/ 2 : null; + }, + ), + super( + semanticChildCount: itemCount, + ); // Like ListView.custom, but with sticky headers. const StickyHeaderListView.custom({ @@ -155,7 +155,7 @@ class SliverStickyHeaderList extends SliverMultiBoxAdaptorWidget { @override SliverMultiBoxAdaptorElement createElement() => - SliverMultiBoxAdaptorElement(this, replaceMovedChildren: true); + SliverMultiBoxAdaptorElement(this, replaceMovedChildren: true); @override RenderSliverStickyHeaderList createRenderObject(BuildContext context) { @@ -194,14 +194,14 @@ class RenderSliverStickyHeaderList extends RenderSliverList { double childScrollOffset; if (innerChild.direction == constraints.axisDirection) { - childScrollOffset = - math.max(0.0, scrollOffset - parentData.layoutOffset!); + childScrollOffset = math.max(0.0, + scrollOffset - parentData.layoutOffset!); } else { final childEndOffset = - parentData.layoutOffset! + child.size.onAxis(constraints.axis); + parentData.layoutOffset! + child.size.onAxis(constraints.axis); // TODO should this be our layoutExtent or paintExtent, or what? - childScrollOffset = math.max( - 0.0, childEndOffset - (scrollOffset + geometry!.layoutExtent)); + childScrollOffset = math.max(0.0, + childEndOffset - (scrollOffset + geometry!.layoutExtent)); } innerChild.provideScrollPosition(childScrollOffset); } @@ -211,11 +211,12 @@ class RenderSliverStickyHeaderList extends RenderSliverList { enum StickyHeaderSlot { header, content } class StickyHeader extends SlottedMultiChildRenderObjectWidget { - const StickyHeader( - {super.key, - this.direction = AxisDirection.down, - this.header, - this.content}); + const StickyHeader({ + super.key, + this.direction = AxisDirection.down, + this.header, + this.content, + }); final AxisDirection direction; final Widget? header; @@ -241,10 +242,9 @@ class StickyHeader extends SlottedMultiChildRenderObjectWidget { +class RenderStickyHeader extends RenderBox with SlottedContainerRenderObjectMixin { RenderStickyHeader({required AxisDirection direction}) - : _direction = direction; + : _direction = direction; RenderBox? get _header => childForSlot(StickyHeaderSlot.header); @@ -260,8 +260,10 @@ class RenderStickyHeader extends RenderBox } @override - Iterable get children => - [if (_header != null) _header!, if (_content != null) _content!]; + Iterable get children => [ + if (_header != null) _header!, + if (_content != null) _content!, + ]; double? _slackSize; @@ -351,8 +353,7 @@ class RenderStickyHeader extends RenderBox return false; } - BoxParentData _parentData(RenderBox child) => - child.parentData! as BoxParentData; + BoxParentData _parentData(RenderBox child) => child.parentData! as BoxParentData; } Size sizeOn(Axis axis, {double main = 0, double cross = 0}) { diff --git a/lib/widgets/store.dart b/lib/widgets/store.dart index 9a8cbcf86c..8d1c02cf61 100644 --- a/lib/widgets/store.dart +++ b/lib/widgets/store.dart @@ -38,8 +38,7 @@ class GlobalStoreWidget extends StatefulWidget { /// * [PerAccountStoreWidget.of], for the user's data associated with a /// particular Zulip account. static GlobalStore of(BuildContext context) { - final widget = context - .dependOnInheritedWidgetOfExactType<_GlobalStoreInheritedWidget>(); + final widget = context.dependOnInheritedWidgetOfExactType<_GlobalStoreInheritedWidget>(); assert(widget != null, 'No GlobalStoreWidget ancestor'); return widget!.store; } @@ -75,15 +74,16 @@ class _GlobalStoreWidgetState extends State { // a [StatefulWidget] to get hold of the store, and an [InheritedWidget] to // provide it to descendants, and one widget can't be both of those. class _GlobalStoreInheritedWidget extends InheritedNotifier { - const _GlobalStoreInheritedWidget( - {required GlobalStore store, required super.child}) - : super(notifier: store); + const _GlobalStoreInheritedWidget({ + required GlobalStore store, + required super.child, + }) : super(notifier: store); GlobalStore get store => notifier!; @override bool updateShouldNotify(covariant _GlobalStoreInheritedWidget oldWidget) => - store != oldWidget.store; + store != oldWidget.store; } /// Provides access to the user's data for a particular Zulip account. @@ -103,8 +103,11 @@ class _GlobalStoreInheritedWidget extends InheritedNotifier { /// * [GlobalStoreWidget], for the app's data beyond that of a /// particular account. class PerAccountStoreWidget extends StatefulWidget { - const PerAccountStoreWidget( - {super.key, required this.accountId, required this.child}); + const PerAccountStoreWidget({ + super.key, + required this.accountId, + required this.child, + }); final int accountId; final Widget child; @@ -137,8 +140,7 @@ class PerAccountStoreWidget extends StatefulWidget { /// particular account. /// * [InheritedNotifier], which provides the "dependency" mechanism. static PerAccountStore of(BuildContext context) { - final widget = context - .dependOnInheritedWidgetOfExactType<_PerAccountStoreInheritedWidget>(); + final widget = context.dependOnInheritedWidgetOfExactType<_PerAccountStoreInheritedWidget>(); assert(widget != null, 'No PerAccountStoreWidget ancestor'); return widget!.store; } @@ -223,15 +225,16 @@ class _PerAccountStoreWidgetState extends State { // [StatefulWidget] to get hold of the store, and an [InheritedWidget] to // provide it to descendants, and one widget can't be both of those. class _PerAccountStoreInheritedWidget extends InheritedNotifier { - const _PerAccountStoreInheritedWidget( - {required PerAccountStore store, required super.child}) - : super(notifier: store); + const _PerAccountStoreInheritedWidget({ + required PerAccountStore store, + required super.child, + }) : super(notifier: store); PerAccountStore get store => notifier!; @override bool updateShouldNotify(covariant _PerAccountStoreInheritedWidget oldWidget) => - store != oldWidget.store; + store != oldWidget.store; } class LoadingPage extends StatelessWidget { diff --git a/test/api/model/events_test.dart b/test/api/model/events_test.dart index 65ea26cd5e..542bd59b41 100644 --- a/test/api/model/events_test.dart +++ b/test/api/model/events_test.dart @@ -10,11 +10,11 @@ void main() { test('message: move flags into message object', () { final message = eg.streamMessage(); MessageEvent mkEvent(List flags) => Event.fromJson({ - 'type': 'message', - 'id': 1, - 'message': message.toJson()..remove('flags'), - 'flags': flags, - }) as MessageEvent; + 'type': 'message', + 'id': 1, + 'message': message.toJson()..remove('flags'), + 'flags': flags, + }) as MessageEvent; check(mkEvent(message.flags)).message.jsonEquals(message); check(mkEvent([])).message.flags.deepEquals([]); check(mkEvent(['read'])).message.flags.deepEquals(['read']); diff --git a/test/api/route/messages_test.dart b/test/api/route/messages_test.dart index 193c410364..d74e693e87 100644 --- a/test/api/route/messages_test.dart +++ b/test/api/route/messages_test.dart @@ -8,17 +8,17 @@ import 'route_checks.dart'; void main() { test('sendMessage accepts fixture realm', () async { final connection = FakeApiConnection( - realmUrl: Uri.parse('https://chat.zulip.org/')); + realmUrl: Uri.parse('https://chat.zulip.org/')); connection.prepare(json: SendMessageResult(id: 42).toJson()); check(sendMessage(connection, content: 'hello', topic: 'world')) - .completes(it()..id.equals(42)); + .completes(it()..id.equals(42)); }); test('sendMessage rejects unexpected realm', () async { final connection = FakeApiConnection( - realmUrl: Uri.parse('https://chat.example/')); + realmUrl: Uri.parse('https://chat.example/')); connection.prepare(json: SendMessageResult(id: 42).toJson()); check(() => sendMessage(connection, content: 'hello', topic: 'world')) - .throws(); + .throws(); }); } diff --git a/test/model/store_test.dart b/test/model/store_test.dart index 409318f91d..f27ae27882 100644 --- a/test/model/store_test.dart +++ b/test/model/store_test.dart @@ -12,7 +12,7 @@ void main() { final accounts = {1: eg.selfAccount, 2: eg.otherAccount}; final globalStore = TestGlobalStore(accounts: accounts); List> completers(int accountId) => - globalStore.completers[accounts[accountId]]!; + globalStore.completers[accounts[accountId]]!; final future1 = globalStore.perAccount(1); final store1 = PerAccountStore.fromInitialSnapshot( @@ -27,9 +27,9 @@ void main() { final future2 = globalStore.perAccount(2); final store2 = PerAccountStore.fromInitialSnapshot( - account: eg.otherAccount, - connection: FakeApiConnection.fromAccount(eg.otherAccount), - initialSnapshot: eg.initialSnapshot, + account: eg.otherAccount, + connection: FakeApiConnection.fromAccount(eg.otherAccount), + initialSnapshot: eg.initialSnapshot, ); completers(2).single.complete(store2); check(await future2).identicalTo(store2); @@ -45,7 +45,7 @@ void main() { final accounts = {1: eg.selfAccount, 2: eg.otherAccount}; final globalStore = TestGlobalStore(accounts: accounts); List> completers(int accountId) => - globalStore.completers[accounts[accountId]]!; + globalStore.completers[accounts[accountId]]!; final future1a = globalStore.perAccount(1); final future1b = globalStore.perAccount(1); @@ -78,7 +78,7 @@ void main() { final accounts = {1: eg.selfAccount, 2: eg.otherAccount}; final globalStore = TestGlobalStore(accounts: accounts); List> completers(int accountId) => - globalStore.completers[accounts[accountId]]!; + globalStore.completers[accounts[accountId]]!; check(globalStore.perAccountSync(1)).isNull(); final future1 = globalStore.perAccount(1);