Skip to content

Commit

Permalink
stream colors [nfc]: Move StreamColorSwatch out to a new file in widg…
Browse files Browse the repository at this point in the history
…ets/

Toward zulip#393, "model: Track/memoize stream-color variants somewhere
other than /api/model".

But this only moves definitions; we'll reassign the
caching/computing logic in an upcoming commit.

Related: zulip#393
  • Loading branch information
chrisbobbe committed Jun 18, 2024
1 parent 5285123 commit f02340d
Show file tree
Hide file tree
Showing 9 changed files with 604 additions and 586 deletions.
154 changes: 1 addition & 153 deletions lib/api/model/model.dart
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
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 '../../widgets/stream_colors.dart';
import 'events.dart';
import 'initial_snapshot.dart';
import 'reaction.dart';
Expand Down Expand Up @@ -459,156 +457,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
157 changes: 157 additions & 0 deletions lib/widgets/stream_colors.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import 'dart:ui';

import 'package:flutter/material.dart';
import 'package:flutter_color_models/flutter_color_models.dart';

import '../api/model/model.dart';
import 'color.dart';

/// 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,
}
2 changes: 1 addition & 1 deletion lib/widgets/unread_count_badge.dart
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@

import 'package:flutter/material.dart';

import '../api/model/model.dart';
import 'stream_colors.dart';
import 'text.dart';

/// A widget to display a given number of unreads in a conversation.
Expand Down
10 changes: 0 additions & 10 deletions test/api/model/model_checks.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import 'dart:ui';

import 'package:checks/checks.dart';
import 'package:zulip/api/model/model.dart';

Expand Down Expand Up @@ -29,14 +27,6 @@ extension ZulipStreamChecks on Subject<ZulipStream> {
Subject<int?> get canRemoveSubscribersGroup => has((e) => e.canRemoveSubscribersGroup, 'canRemoveSubscribersGroup');
}

extension StreamColorSwatchChecks on Subject<StreamColorSwatch> {
Subject<Color> get base => has((s) => s.base, 'base');
Subject<Color> get unreadCountBadgeBackground => has((s) => s.unreadCountBadgeBackground, 'unreadCountBadgeBackground');
Subject<Color> get iconOnPlainBackground => has((s) => s.iconOnPlainBackground, 'iconOnPlainBackground');
Subject<Color> get iconOnBarBackground => has((s) => s.iconOnBarBackground, 'iconOnBarBackground');
Subject<Color> get barBackground => has((s) => s.barBackground, 'barBackground');
}

extension MessageChecks on Subject<Message> {
Subject<int> get id => has((e) => e.id, 'id');
Subject<String> get content => has((e) => e.content, 'content');
Expand Down
Loading

0 comments on commit f02340d

Please sign in to comment.