Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ui: Provide [StreamColorSwatch]es via DesignVariables, not api/model/ #746

Merged
176 changes: 3 additions & 173 deletions lib/api/model/model.dart
Original file line number Diff line number Diff line change
@@ -1,9 +1,5 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart';
import 'package:flutter_color_models/flutter_color_models.dart';
import 'package:json_annotation/json_annotation.dart';

import '../../widgets/color.dart';
import 'events.dart';
import 'initial_snapshot.dart';
import 'reaction.dart';
Expand Down Expand Up @@ -405,29 +401,13 @@ class Subscription extends ZulipStream {
/// As an int that dart:ui's Color constructor will take:
/// <https://api.flutter.dev/flutter/dart-ui/Color/Color.html>
@JsonKey(readValue: _readColor)
int get color => _color;
int _color;
set color(int value) {
_color = value;
_swatch = null;
}
int color;
static Object? _readColor(Map<dynamic, dynamic> json, String key) {
final str = (json[key] as String);
assert(RegExp(r'^#[0-9a-f]{6}$').hasMatch(str));
return 0xff000000 | int.parse(str.substring(1), radix: 16);
}

StreamColorSwatch? _swatch;
/// A [StreamColorSwatch] for the subscription, memoized.
// TODO I'm not sure this is the right home for this; it seems like we might
// instead have chosen to put it in more UI-centered code, like in a custom
// material [ColorScheme] class or something. But it works for now.
StreamColorSwatch colorSwatch() => _swatch ??= StreamColorSwatch.light(color);

@visibleForTesting
@JsonKey(includeToJson: false)
StreamColorSwatch? get debugCachedSwatchValue => _swatch;

Subscription({
required super.streamId,
required super.name,
Expand All @@ -449,8 +429,8 @@ class Subscription extends ZulipStream {
required this.audibleNotifications,
required this.pinToTop,
required this.isMuted,
required int color,
}) : _color = color;
required this.color,
});

factory Subscription.fromJson(Map<String, dynamic> json) =>
_$SubscriptionFromJson(json);
Expand All @@ -459,156 +439,6 @@ class Subscription extends ZulipStream {
Map<String, dynamic> toJson() => _$SubscriptionToJson(this);
}

/// A [ColorSwatch] with colors related to a base stream color.
///
/// Use this in UI code for colors related to [Subscription.color],
/// such as the background of an unread count badge.
class StreamColorSwatch extends ColorSwatch<StreamColorVariant> {
StreamColorSwatch.light(int base) : this._(base, _computeLight(base));
StreamColorSwatch.dark(int base) : this._(base, _computeDark(base));

const StreamColorSwatch._(int base, this._swatch) : super(base, _swatch);

final Map<StreamColorVariant, Color> _swatch;

/// The [Subscription.color] int that the swatch is based on.
Color get base => this[StreamColorVariant.base]!;

Color get unreadCountBadgeBackground => this[StreamColorVariant.unreadCountBadgeBackground]!;

/// The stream icon on a plain-colored surface, such as white.
///
/// For the icon on a [barBackground]-colored surface,
/// use [iconOnBarBackground] instead.
Color get iconOnPlainBackground => this[StreamColorVariant.iconOnPlainBackground]!;

/// The stream icon on a [barBackground]-colored surface.
///
/// For the icon on a plain surface, use [iconOnPlainBackground] instead.
/// This color is chosen to enhance contrast with [barBackground]:
/// <https://github.com/zulip/zulip/pull/27485>
Color get iconOnBarBackground => this[StreamColorVariant.iconOnBarBackground]!;

/// The background color of a bar representing a stream, like a recipient bar.
///
/// Use this in the message list, the "Inbox" view, and the "Streams" view.
Color get barBackground => this[StreamColorVariant.barBackground]!;

static Map<StreamColorVariant, Color> _computeLight(int base) {
final baseAsColor = Color(base);

final clamped20to75 = clampLchLightness(baseAsColor, 20, 75);
final clamped20to75AsHsl = HSLColor.fromColor(clamped20to75);

return {
StreamColorVariant.base: baseAsColor,

// Follows `.unread-count` in Vlad's replit:
// <https://replit.com/@VladKorobov/zulip-sidebar#script.js>
// <https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/design.3A.20.23F117.20.22Inbox.22.20screen/near/1624484>
//
// TODO fix bug where our results differ from the replit's (see unit tests)
StreamColorVariant.unreadCountBadgeBackground:
clampLchLightness(baseAsColor, 30, 70)
.withOpacity(0.3),

// Follows `.sidebar-row__icon` in Vlad's replit:
// <https://replit.com/@VladKorobov/zulip-sidebar#script.js>
//
// TODO fix bug where our results differ from the replit's (see unit tests)
StreamColorVariant.iconOnPlainBackground: clamped20to75,

// Follows `.recepeient__icon` in Vlad's replit:
// <https://replit.com/@VladKorobov/zulip-topic-feed-colors#script.js>
// <https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/design.3A.20.23F117.20.22Inbox.22.20screen/near/1624484>
//
// TODO fix bug where our results differ from the replit's (see unit tests)
StreamColorVariant.iconOnBarBackground:
clamped20to75AsHsl
.withLightness(clampDouble(clamped20to75AsHsl.lightness - 0.12, 0.0, 1.0))
.toColor(),

// Follows `.recepient` in Vlad's replit:
// <https://replit.com/@VladKorobov/zulip-topic-feed-colors#script.js>
//
// TODO I think [LabColor.interpolate] doesn't actually do LAB mixing;
// it just calls up to the superclass method [ColorModel.interpolate]:
// <https://pub.dev/documentation/flutter_color_models/latest/flutter_color_models/ColorModel/interpolate.html>
// which does ordinary RGB mixing. Investigate and send a PR?
// TODO fix bug where our results differ from the replit's (see unit tests)
StreamColorVariant.barBackground:
LabColor.fromColor(const Color(0xfff9f9f9))
.interpolate(LabColor.fromColor(clamped20to75), 0.22)
.toColor(),
};
}

static Map<StreamColorVariant, Color> _computeDark(int base) {
final baseAsColor = Color(base);

final clamped20to75 = clampLchLightness(baseAsColor, 20, 75);

return {
// See comments in [_computeLight] about what these computations are based
// on, and how the resulting values are a little off sometimes. The
// comments mostly apply here too.

StreamColorVariant.base: baseAsColor,
StreamColorVariant.unreadCountBadgeBackground:
clampLchLightness(baseAsColor, 30, 70)
.withOpacity(0.3),
StreamColorVariant.iconOnPlainBackground: clamped20to75,

// Follows the web app (as of zulip/zulip@db03369ac); see
// get_stream_privacy_icon_color in web/src/stream_color.ts.
//
// `.recepeient__icon` in Vlad's replit gives something different so we
// don't use that:
// <https://replit.com/@VladKorobov/zulip-topic-feed-colors#script.js>
// <https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/design.3A.20.23F117.20.22Inbox.22.20screen/near/1624484>
// But that's OK because Vlad said "I feel like current dark theme contrast
// is fine", and when he said that, this had been the web app's icon color
// for 6+ months (since zulip/zulip@023584e04):
// https://chat.zulip.org/#narrow/stream/101-design/topic/UI.20redesign.3A.20recipient.20bar.20colors/near/1675786
//
// TODO fix bug where our results are unexpected (see unit tests)
StreamColorVariant.iconOnBarBackground: clamped20to75,

StreamColorVariant.barBackground:
LabColor.fromColor(const Color(0xff000000))
.interpolate(LabColor.fromColor(clamped20to75), 0.38)
.toColor(),
};
}

/// Copied from [ColorSwatch.lerp].
static StreamColorSwatch? lerp(StreamColorSwatch? a, StreamColorSwatch? b, double t) {
if (identical(a, b)) {
return a;
}
final Map<StreamColorVariant, Color> swatch;
if (b == null) {
swatch = a!._swatch.map((key, color) => MapEntry(key, Color.lerp(color, null, t)!));
} else {
if (a == null) {
swatch = b._swatch.map((key, color) => MapEntry(key, Color.lerp(null, color, t)!));
} else {
swatch = a._swatch.map((key, color) => MapEntry(key, Color.lerp(color, b[key], t)!));
}
}
return StreamColorSwatch._(Color.lerp(a, b, t)!.value, swatch);
}
}

@visibleForTesting
enum StreamColorVariant {
base,
unreadCountBadgeBackground,
iconOnPlainBackground,
iconOnBarBackground,
barBackground,
}

@JsonEnum(fieldRename: FieldRename.snake, valueField: "apiValue")
enum UserTopicVisibilityPolicy {
none(apiValue: 0),
Expand Down
45 changes: 27 additions & 18 deletions lib/widgets/inbox.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import 'page.dart';
import 'sticky_header.dart';
import 'store.dart';
import 'text.dart';
import 'theme.dart';
import 'unread_count_badge.dart';

class InboxPage extends StatefulWidget {
Expand Down Expand Up @@ -226,10 +227,10 @@ abstract class _HeaderItem extends StatelessWidget {

String get title;
IconData get icon;
Color get collapsedIconColor;
Color get uncollapsedIconColor;
Color get uncollapsedBackgroundColor;
Color? get unreadCountBadgeBackgroundColor;
Color collapsedIconColor(BuildContext context);
Color uncollapsedIconColor(BuildContext context);
Color uncollapsedBackgroundColor(BuildContext context);
Color? unreadCountBadgeBackgroundColor(BuildContext context);

Future<void> onCollapseButtonTap() async {
if (!collapsed) {
Expand All @@ -246,7 +247,7 @@ abstract class _HeaderItem extends StatelessWidget {
Widget build(BuildContext context) {
return Material(
// TODO(#95) need dark-theme color
color: collapsed ? Colors.white : uncollapsedBackgroundColor,
color: collapsed ? Colors.white : uncollapsedBackgroundColor(context),
child: InkWell(
// TODO use onRowTap to handle taps that are not on the collapse button.
// Probably we should give the collapse button a 44px or 48px square
Expand All @@ -260,7 +261,10 @@ abstract class _HeaderItem extends StatelessWidget {
// TODO(#95) need dark-theme color
child: Icon(size: 20, color: const Color(0x7F1D2E48),
collapsed ? ZulipIcons.arrow_right : ZulipIcons.arrow_down)),
Icon(size: 18, color: collapsed ? collapsedIconColor : uncollapsedIconColor,
Icon(size: 18,
color: collapsed
? collapsedIconColor(context)
: uncollapsedIconColor(context),
icon),
const SizedBox(width: 5),
Expanded(child: Padding(
Expand All @@ -278,7 +282,9 @@ abstract class _HeaderItem extends StatelessWidget {
const SizedBox(width: 12),
if (hasMention) const _AtMentionMarker(),
Padding(padding: const EdgeInsetsDirectional.only(end: 16),
child: UnreadCountBadge(backgroundColor: unreadCountBadgeBackgroundColor, bold: true,
child: UnreadCountBadge(
backgroundColor: unreadCountBadgeBackgroundColor(context),
bold: true,
count: count)),
])));
}
Expand All @@ -297,11 +303,11 @@ class _AllDmsHeaderItem extends _HeaderItem {
@override IconData get icon => ZulipIcons.user;

// TODO(#95) need dark-theme colors
@override Color get collapsedIconColor => const Color(0xFF222222);
@override Color get uncollapsedIconColor => const Color(0xFF222222);
@override Color collapsedIconColor(context) => const Color(0xFF222222);
@override Color uncollapsedIconColor(context) => const Color(0xFF222222);

@override Color get uncollapsedBackgroundColor => const HSLColor.fromAHSL(1, 46, 0.35, 0.93).toColor();
@override Color? get unreadCountBadgeBackgroundColor => null;
@override Color uncollapsedBackgroundColor(context) => const HSLColor.fromAHSL(1, 46, 0.35, 0.93).toColor();
@override Color? unreadCountBadgeBackgroundColor(context) => null;

@override Future<void> onCollapseButtonTap() async {
await super.onCollapseButtonTap();
Expand Down Expand Up @@ -418,12 +424,14 @@ class _StreamHeaderItem extends _HeaderItem {

@override String get title => subscription.name;
@override IconData get icon => iconDataForStream(subscription);
@override Color get collapsedIconColor => subscription.colorSwatch().iconOnPlainBackground;
@override Color get uncollapsedIconColor => subscription.colorSwatch().iconOnBarBackground;
@override Color get uncollapsedBackgroundColor =>
subscription.colorSwatch().barBackground;
@override Color? get unreadCountBadgeBackgroundColor =>
subscription.colorSwatch().unreadCountBadgeBackground;
@override Color collapsedIconColor(context) =>
colorSwatchFor(context, subscription).iconOnPlainBackground;
@override Color uncollapsedIconColor(context) =>
colorSwatchFor(context, subscription).iconOnBarBackground;
@override Color uncollapsedBackgroundColor(context) =>
colorSwatchFor(context, subscription).barBackground;
@override Color? unreadCountBadgeBackgroundColor(context) =>
colorSwatchFor(context, subscription).unreadCountBadgeBackground;

@override Future<void> onCollapseButtonTap() async {
await super.onCollapseButtonTap();
Expand Down Expand Up @@ -520,7 +528,8 @@ class _TopicItem extends StatelessWidget {
const SizedBox(width: 12),
if (hasMention) const _AtMentionMarker(),
Padding(padding: const EdgeInsetsDirectional.only(end: 16),
child: UnreadCountBadge(backgroundColor: subscription.colorSwatch(),
child: UnreadCountBadge(
backgroundColor: colorSwatchFor(context, subscription),
count: count)),
]))));
}
Expand Down
9 changes: 6 additions & 3 deletions lib/widgets/message_list.dart
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import 'profile.dart';
import 'sticky_header.dart';
import 'store.dart';
import 'text.dart';
import 'theme.dart';

class MessageListPage extends StatefulWidget {
const MessageListPage({super.key, required this.narrow});
Expand Down Expand Up @@ -66,8 +67,10 @@ class _MessageListPageState extends State<MessageListPage> {

case StreamNarrow(:final streamId):
case TopicNarrow(:final streamId):
appBarBackgroundColor = store.subscriptions[streamId]?.colorSwatch().barBackground
?? _kUnsubscribedStreamRecipientHeaderColor;
final subscription = store.subscriptions[streamId];
appBarBackgroundColor = subscription != null
? colorSwatchFor(context, subscription).barBackground
: _kUnsubscribedStreamRecipientHeaderColor;
// All recipient headers will match this color; remove distracting line
// (but are recipient headers even needed for topic narrows?)
removeAppBarBottomBorder = true;
Expand Down Expand Up @@ -663,7 +666,7 @@ class StreamMessageRecipientHeader extends StatelessWidget {
final Color backgroundColor;
final Color iconColor;
if (subscription != null) {
final swatch = subscription.colorSwatch();
final swatch = colorSwatchFor(context, subscription);
backgroundColor = swatch.barBackground;
iconColor = swatch.iconOnBarBackground;
} else {
Expand Down
Loading
Loading