Skip to content

Commit 1a43fe2

Browse files
chrisbobbegnprice
authored andcommitted
action_sheet: Show channel name in channel action sheet
This excludes the channel description for now; that's #1896. Fixes-partly: #1533
1 parent 6795e96 commit 1a43fe2

File tree

4 files changed

+213
-1
lines changed

4 files changed

+213
-1
lines changed

lib/widgets/action_sheet.dart

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -502,7 +502,21 @@ void showChannelActionSheet(BuildContext context, {
502502
[UnsubscribeButton(pageContext: pageContext, channelId: channelId)],
503503
];
504504

505-
_showActionSheet(pageContext, buttonSections: buttonSections);
505+
final header = BottomSheetHeader(
506+
buildTitle: (baseStyle) => Text.rich(
507+
style: baseStyle,
508+
channelTopicLabelSpan(
509+
context: context,
510+
channelId: channelId,
511+
fontSize: baseStyle.fontSize!,
512+
color: baseStyle.color!)),
513+
// TODO(#1896) show channel description
514+
);
515+
516+
_showActionSheet(pageContext,
517+
header: header,
518+
headerScrollable: false,
519+
buttonSections: buttonSections);
506520
}
507521

508522
class SubscribeButton extends ActionSheetMenuItemButton {

lib/widgets/text.dart

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ import 'package:flutter/foundation.dart';
44
import 'package:flutter/gestures.dart';
55
import 'package:flutter/material.dart';
66

7+
import '../generated/l10n/zulip_localizations.dart';
8+
import 'icons.dart';
9+
import 'store.dart';
710
import 'theme.dart';
811

912
/// An app-wide [Typography] for Zulip, customized from the Material default.
@@ -533,3 +536,139 @@ class _TextWithLinkState extends State<TextWithLink> {
533536
span);
534537
}
535538
}
539+
540+
/// Data to size and position a square icon in a span of text.
541+
class InlineIconGeometryData {
542+
/// What size the icon should be,
543+
/// as a fraction of the surrounding text's font size.
544+
final double sizeFactor;
545+
546+
/// Where to assign the icon's baseline, as a fraction of the icon's size,
547+
/// when the span is rendered with [TextBaseline.alphabetic].
548+
///
549+
/// This is ignored when the span is rendered with [TextBaseline.ideographic];
550+
/// zero is used instead.
551+
final double alphabeticBaselineFactor;
552+
553+
/// How much horizontal padding should separate the icon from surrounding text,
554+
/// as a fraction of the icon's size.
555+
final double paddingFactor;
556+
557+
const InlineIconGeometryData._({
558+
required this.sizeFactor,
559+
required this.alphabeticBaselineFactor,
560+
required this.paddingFactor,
561+
});
562+
563+
factory InlineIconGeometryData.forIcon(IconData icon) {
564+
final result = _inlineIconGeometries[icon];
565+
assert(result != null);
566+
return result ?? _defaultGeometry;
567+
}
568+
569+
// Values are ad hoc unless otherwise specified.
570+
static final Map<IconData, InlineIconGeometryData> _inlineIconGeometries = {
571+
ZulipIcons.globe: InlineIconGeometryData._(
572+
sizeFactor: 0.8,
573+
alphabeticBaselineFactor: 1 / 8,
574+
paddingFactor: 1 / 4),
575+
576+
ZulipIcons.hash_sign: InlineIconGeometryData._(
577+
sizeFactor: 0.8,
578+
alphabeticBaselineFactor: 1 / 16,
579+
paddingFactor: 1 / 4),
580+
581+
ZulipIcons.lock: InlineIconGeometryData._(
582+
sizeFactor: 0.8,
583+
alphabeticBaselineFactor: 1 / 16,
584+
paddingFactor: 1 / 4),
585+
};
586+
587+
static final _defaultGeometry = InlineIconGeometryData._(
588+
sizeFactor: 0.8,
589+
alphabeticBaselineFactor: 1 / 16,
590+
paddingFactor: 1 / 4,
591+
);
592+
}
593+
594+
/// An icon, sized and aligned for use in a span of text.
595+
WidgetSpan iconWidgetSpan({
596+
required IconData icon,
597+
required double fontSize,
598+
required TextBaseline baselineType,
599+
required Color? color,
600+
bool padBefore = false,
601+
bool padAfter = false,
602+
}) {
603+
final InlineIconGeometryData(
604+
:sizeFactor,
605+
:alphabeticBaselineFactor,
606+
:paddingFactor,
607+
) = InlineIconGeometryData.forIcon(icon);
608+
609+
final size = sizeFactor * fontSize;
610+
611+
final effectiveBaselineOffset = switch (baselineType) {
612+
TextBaseline.alphabetic => alphabeticBaselineFactor * size,
613+
TextBaseline.ideographic => 0.0,
614+
};
615+
616+
Widget child = Icon(size: size, color: color, icon);
617+
618+
if (effectiveBaselineOffset != 0) {
619+
child = Transform.translate(
620+
offset: Offset(0, effectiveBaselineOffset),
621+
child: child);
622+
}
623+
624+
if (padBefore || padAfter) {
625+
final padding = paddingFactor * size;
626+
child = Padding(
627+
padding: EdgeInsetsDirectional.only(
628+
start: padBefore ? padding : 0,
629+
end: padAfter ? padding : 0,
630+
),
631+
child: child);
632+
}
633+
634+
return WidgetSpan(
635+
alignment: PlaceholderAlignment.baseline,
636+
baseline: baselineType,
637+
child: child);
638+
}
639+
640+
/// An [InlineSpan] with a channel privacy icon and channel name.
641+
///
642+
/// Pass this to [Text.rich], which can be styled arbitrarily.
643+
/// Pass the [fontSize] of surrounding text
644+
/// so that the icon is sized appropriately.
645+
InlineSpan channelTopicLabelSpan({
646+
required BuildContext context,
647+
required int channelId,
648+
required double fontSize,
649+
required Color color,
650+
}) {
651+
final zulipLocalizations = ZulipLocalizations.of(context);
652+
final store = PerAccountStoreWidget.of(context);
653+
final channel = store.streams[channelId];
654+
final subscription = store.subscriptions[channelId];
655+
final swatch = colorSwatchFor(context, subscription);
656+
final channelIcon = channel != null ? iconDataForStream(channel) : null;
657+
final baselineType = localizedTextBaseline(context);
658+
659+
return TextSpan(children: [
660+
if (channelIcon != null)
661+
iconWidgetSpan(
662+
icon: channelIcon,
663+
fontSize: fontSize,
664+
baselineType: baselineType,
665+
color: swatch.iconOnPlainBackground,
666+
padAfter: true),
667+
if (channel != null)
668+
TextSpan(text: channel.name)
669+
else
670+
TextSpan(
671+
style: TextStyle(fontStyle: FontStyle.italic),
672+
text: zulipLocalizations.unknownChannelName),
673+
]);
674+
}

test/api/model/model_checks.dart

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,9 @@ extension SavedSnippetChecks on Subject<SavedSnippet> {
5454

5555
extension ZulipStreamChecks on Subject<ZulipStream> {
5656
Subject<int> get streamId => has((x) => x.streamId, 'streamId');
57+
58+
Subject<bool> get inviteOnly => has((x) => x.inviteOnly, 'inviteOnly');
59+
Subject<bool> get isWebPublic => has((x) => x.isWebPublic, 'isWebPublic');
5760
}
5861

5962
extension ChannelFolderChecks on Subject<ChannelFolder> {

test/widgets/action_sheet_test.dart

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import 'package:zulip/widgets/topic_list.dart';
3838
import 'package:zulip/widgets/user.dart';
3939
import '../api/fake_api.dart';
4040

41+
import '../api/model/model_checks.dart';
4142
import '../example_data.dart' as eg;
4243
import '../flutter_checks.dart';
4344
import '../model/binding.dart';
@@ -292,6 +293,61 @@ void main() {
292293
checkButton('Copy link to channel');
293294
}
294295

296+
group('header', () {
297+
final findHeader = find.descendant(
298+
of: actionSheetFinder,
299+
matching: find.byType(BottomSheetHeader));
300+
301+
Finder findInHeader(Finder finder) =>
302+
find.descendant(of: findHeader, matching: finder);
303+
304+
testWidgets('public channel', (tester) async {
305+
await prepare();
306+
check(store.streams[someChannel.streamId]).isNotNull()
307+
..inviteOnly.isFalse()..isWebPublic.isFalse();
308+
await showFromInbox(tester);
309+
check(findInHeader(find.byIcon(ZulipIcons.hash_sign))).findsOne();
310+
check(findInHeader(find.textContaining(someChannel.name))).findsOne();
311+
});
312+
313+
testWidgets('web-public channel', (tester) async {
314+
await prepare();
315+
await store.handleEvent(ChannelUpdateEvent(id: 1,
316+
streamId: someChannel.streamId,
317+
name: someChannel.name,
318+
property: null, value: null,
319+
// (Ideally we'd use `property` and `value` but I'm not sure if
320+
// modern servers actually do that or if they still use this
321+
// separate field.)
322+
isWebPublic: true));
323+
check(store.streams[someChannel.streamId]).isNotNull()
324+
..inviteOnly.isFalse()..isWebPublic.isTrue();
325+
await showFromInbox(tester);
326+
check(findInHeader(find.byIcon(ZulipIcons.globe))).findsOne();
327+
check(findInHeader(find.textContaining(someChannel.name))).findsOne();
328+
});
329+
330+
testWidgets('private channel', (tester) async {
331+
await prepare();
332+
await store.handleEvent(eg.channelUpdateEvent(someChannel,
333+
property: ChannelPropertyName.inviteOnly, value: true));
334+
check(store.streams[someChannel.streamId]).isNotNull()
335+
..inviteOnly.isTrue()..isWebPublic.isFalse();
336+
await showFromInbox(tester);
337+
check(findInHeader(find.byIcon(ZulipIcons.lock))).findsOne();
338+
check(findInHeader(find.textContaining(someChannel.name))).findsOne();
339+
});
340+
341+
testWidgets('unknown channel', (tester) async {
342+
await prepare();
343+
await store.handleEvent(ChannelDeleteEvent(id: 1, streams: [someChannel]));
344+
check(store.streams[someChannel.streamId]).isNull();
345+
await showFromTopicListAppBar(tester);
346+
check(findInHeader(find.byType(Icon))).findsNothing();
347+
check(findInHeader(find.textContaining('(unknown channel)'))).findsOne();
348+
});
349+
});
350+
295351
testWidgets('show from inbox', (tester) async {
296352
await prepare();
297353
await showFromInbox(tester);

0 commit comments

Comments
 (0)