add FrostyColors ThemeExtension, align hardcoded colors with M3 roles

Introduce FrostyColors ThemeExtension with 7 semantic color fields
(highlightedMessage, emoteOnly, followersOnly, slowMode, subOnly,
uniqueMode, overlayOnSurface) and brightness-aware light/dark variants.

Replace hardcoded colors across 15 files:
- Links use colorScheme.primary instead of Colors.blue
- Mentions use colorScheme.error instead of Colors.red
- First-time chatters share highlightedMessage (Twitch purple)
- Chat mode indicators use frostyColors fields
- Overlay text uses frostyColors.overlayOnSurface

Remove Provider<FrostyThemes> pattern from main.dart, channel.dart,
video_overlay.dart, and frosty_photo_view_dialog.dart.

Replace manual onSurface.withValues(alpha: 0.6) with M3's built-in
onSurfaceVariant for secondary text in section headers, chat modes,
alerts, loading indicators, and input hints. Fix double-muted
onSurfaceVariant.withValues(alpha: 0.6) in navigation icons and
notification dismiss button.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Tommy Chow 2026-03-01 19:01:44 -08:00
parent 59f5d2cb81
commit 96bce87a0e
19 changed files with 158 additions and 84 deletions

View file

@ -164,20 +164,17 @@ class _MyAppState extends State<MyApp> {
colorSchemeSeed: Color(settingsStore.accentColor),
);
return Provider<FrostyThemes>(
create: (_) => themes,
child: MaterialApp(
title: 'Frosty',
theme: themes.light,
darkTheme: themes.dark,
themeMode: settingsStore.themeType == ThemeType.system
? ThemeMode.system
: settingsStore.themeType == ThemeType.light
? ThemeMode.light
: ThemeMode.dark,
home: widget.firstRun ? const OnboardingIntro() : const Home(),
navigatorKey: navigatorKey,
),
return MaterialApp(
title: 'Frosty',
theme: themes.light,
darkTheme: themes.dark,
themeMode: settingsStore.themeType == ThemeType.system
? ThemeMode.system
: settingsStore.themeType == ThemeType.light
? ThemeMode.light
: ThemeMode.dark,
home: widget.firstRun ? const OnboardingIntro() : const Home(),
navigatorKey: navigatorKey,
);
},
);

View file

@ -442,8 +442,9 @@ class IRCMessage {
bool launchExternal,
TextStyle? textStyle,
void Function(String)? onTapPingedUser,
void Function()? onTapDeletedMessage,
) {
void Function()? onTapDeletedMessage, {
Color? linkColor,
}) {
if (!showMessage) {
span.add(
TextSpan(
@ -493,6 +494,7 @@ class IRCMessage {
emoteScale,
textStyle,
launchExternal,
linkColor: linkColor,
);
} else {
localSpan.add(
@ -524,6 +526,7 @@ class IRCMessage {
_createTextSpan(
text: word,
style: textStyle,
linkColor: linkColor,
launchExternal: launchExternal,
onTapPingedUser: onTapPingedUser,
),
@ -550,8 +553,9 @@ class IRCMessage {
double emoteSize,
double emoteScale,
TextStyle? textStyle,
bool launchExternal,
) {
bool launchExternal, {
Color? linkColor,
}) {
final emoteStack = <Emote>[];
var index = startIndex;
@ -631,6 +635,7 @@ class IRCMessage {
_createTextSpan(
text: words[index],
style: textStyle,
linkColor: linkColor,
launchExternal: launchExternal,
),
);
@ -661,6 +666,7 @@ class IRCMessage {
final emoteToObject = assetsStore.emoteToObject;
final badgeSize = defaultBadgeSize * badgeScale;
final emoteSize = defaultEmoteSize * emoteScale;
final linkColor = Theme.of(context).colorScheme.primary;
// The span list that will be used to render the chat message
final span = <InlineSpan>[];
@ -702,6 +708,7 @@ class IRCMessage {
textStyle,
onTapPingedUser,
onTapDeletedMessage,
linkColor: linkColor,
);
return span;
@ -810,6 +817,7 @@ class IRCMessage {
required String text,
required bool launchExternal,
TextStyle? style,
Color? linkColor,
Function(String)? onTapPingedUser,
}) {
if (text.startsWith('@')) {
@ -827,9 +835,9 @@ class IRCMessage {
return TextSpan(
text: text,
style: style?.copyWith(
color: Colors.blue,
color: linkColor,
decoration: TextDecoration.underline,
decorationColor: Colors.blue,
decorationColor: linkColor,
),
recognizer: TapGestureRecognizer()
..onTap = () => launchUrl(

View file

@ -16,7 +16,6 @@ import 'package:frosty/utils/context_extensions.dart';
import 'package:frosty/widgets/blurred_container.dart';
import 'package:frosty/widgets/draggable_divider.dart';
import 'package:frosty/widgets/frosty_notification.dart';
import 'package:provider/provider.dart';
import 'package:simple_pip_mode/actions/pip_actions_layout.dart';
import 'package:simple_pip_mode/pip_widget.dart';
@ -445,11 +444,7 @@ class _VideoChatState extends State<VideoChat>
).dark,
child: DefaultTextStyle(
style: context.defaultTextStyle.copyWith(
color: context
.watch<FrostyThemes>()
.dark
.colorScheme
.onSurface,
color: context.frostyColors.overlayOnSurface,
),
child: landscapeChat,
),

View file

@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/models/irc.dart';
import 'package:frosty/utils/context_extensions.dart';
class ChatModes extends StatelessWidget {
final ROOMSTATE roomState;
@ -27,7 +28,7 @@ class ChatModes extends StatelessWidget {
label: 'Emote only',
activeLabel: 'Emote only',
isActive: true,
activeColor: const Color(0xFFFFB74D), // Slightly lighter orange
activeColor: context.frostyColors.emoteOnly,
),
),
);
@ -44,7 +45,7 @@ class ChatModes extends StatelessWidget {
label: 'Follower only',
activeLabel: 'Follower only',
isActive: true,
activeColor: const Color(0xFFF44336), // Bright red
activeColor: context.frostyColors.followersOnly,
duration: _getFollowersDuration(),
),
),
@ -62,7 +63,7 @@ class ChatModes extends StatelessWidget {
label: 'Slow mode',
activeLabel: 'Slow mode',
isActive: true,
activeColor: const Color(0xFF2196F3), // Bright blue
activeColor: context.frostyColors.slowMode,
duration: _getSlowModeDuration(),
),
),
@ -80,7 +81,7 @@ class ChatModes extends StatelessWidget {
label: 'Sub only',
activeLabel: 'Sub only',
isActive: true,
activeColor: const Color(0xFF4CAF50), // Bright green
activeColor: context.frostyColors.subOnly,
),
),
);
@ -97,7 +98,7 @@ class ChatModes extends StatelessWidget {
label: 'Unique mode',
activeLabel: 'Unique mode',
isActive: true,
activeColor: const Color(0xFFAB47BC), // Slightly lighter purple
activeColor: context.frostyColors.uniqueMode,
),
),
);
@ -170,7 +171,7 @@ class ChatModes extends StatelessWidget {
style: TextStyle(
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.6),
).colorScheme.onSurfaceVariant,
),
),
],

View file

@ -8,6 +8,7 @@ import 'package:frosty/models/irc.dart';
import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
import 'package:frosty/screens/channel/chat/widgets/chat_user_modal.dart';
import 'package:frosty/screens/channel/chat/widgets/reply_thread.dart';
import 'package:frosty/utils/context_extensions.dart';
import 'package:frosty/utils/modal_bottom_sheet.dart';
import 'package:provider/provider.dart';
@ -252,7 +253,7 @@ class ChatMessage extends StatelessWidget {
),
);
} else if (shouldHighlightFirstMessage) {
highlightColor = Colors.purple;
highlightColor = context.frostyColors.highlightedMessage;
messageHeaderIcon = Icon(
Icons.auto_awesome_rounded,
size: messageHeaderIconSize,
@ -266,7 +267,7 @@ class ChatMessage extends StatelessWidget {
),
);
} else if (shouldHighlightMessage) {
highlightColor = const Color(0xff9146ff);
highlightColor = context.frostyColors.highlightedMessage;
messageHeader = Text(
'Highlighted message',
style: TextStyle(
@ -277,7 +278,7 @@ class ChatMessage extends StatelessWidget {
}
// If user is being mentioned in the message, highlight it red.
if (ircMessage.mention == true) highlightColor = Colors.red;
if (ircMessage.mention == true) highlightColor = context.colorScheme.error;
if (messageHeader != null) {
renderMessage = Column(
@ -401,7 +402,7 @@ class ChatMessage extends StatelessWidget {
break;
case Command.userNotice:
if (chatStore.settings.showUserNotices) {
highlightColor = const Color(0xff9146ff);
highlightColor = context.frostyColors.highlightedMessage;
Widget? messageHeaderIcon;
Widget? messageHeader;

View file

@ -17,7 +17,6 @@ import 'package:frosty/widgets/section_header.dart';
import 'package:frosty/widgets/uptime.dart';
import 'package:intl/intl.dart';
import 'package:native_device_orientation/native_device_orientation.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Creates a widget containing controls which enable interactions with an underlying [Video] widget.
@ -51,11 +50,7 @@ class VideoOverlay extends StatelessWidget {
@override
Widget build(BuildContext context) {
final surfaceColor = context
.watch<FrostyThemes>()
.dark
.colorScheme
.onSurface;
final surfaceColor = context.frostyColors.overlayOnSurface;
final backButton = IconButton(
tooltip: 'Back',

View file

@ -165,9 +165,7 @@ class _HomeState extends State<Home> {
Icons.favorite_border_rounded,
color: _homeStore.selectedIndex == 0
? theme.colorScheme.onSurface
: theme.colorScheme.onSurfaceVariant.withValues(
alpha: 0.6,
),
: theme.colorScheme.onSurfaceVariant,
),
selectedIcon: Icon(
Icons.favorite_rounded,
@ -183,9 +181,7 @@ class _HomeState extends State<Home> {
_homeStore.selectedIndex ==
(_authStore.isLoggedIn ? 1 : 0)
? theme.colorScheme.onSurface
: theme.colorScheme.onSurfaceVariant.withValues(
alpha: 0.6,
),
: theme.colorScheme.onSurfaceVariant,
),
selectedIcon: Icon(
Icons.arrow_upward_rounded,
@ -201,9 +197,7 @@ class _HomeState extends State<Home> {
_homeStore.selectedIndex ==
(_authStore.isLoggedIn ? 2 : 1)
? theme.colorScheme.onSurface
: theme.colorScheme.onSurfaceVariant.withValues(
alpha: 0.6,
),
: theme.colorScheme.onSurfaceVariant,
),
selectedIcon: Icon(
Icons.search_rounded,

View file

@ -6,8 +6,8 @@ import 'package:frosty/models/stream.dart';
import 'package:frosty/screens/channel/channel.dart';
import 'package:frosty/screens/home/top/categories/category_streams.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/theme.dart';
import 'package:frosty/utils.dart';
import 'package:frosty/utils/context_extensions.dart';
import 'package:frosty/utils/modal_bottom_sheet.dart';
import 'package:frosty/widgets/frosty_cached_network_image.dart';
import 'package:frosty/widgets/frosty_photo_view_dialog.dart';
@ -111,11 +111,7 @@ class StreamCard extends StatelessWidget {
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: context
.watch<FrostyThemes>()
.dark
.colorScheme
.onSurface,
color: context.frostyColors.overlayOnSurface,
),
),
),

View file

@ -9,6 +9,7 @@ import 'package:frosty/screens/onboarding/onboarding_scaffold.dart';
import 'package:frosty/screens/onboarding/onboarding_welcome.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:frosty/screens/settings/widgets/settings_list_switch.dart';
import 'package:frosty/utils/context_extensions.dart';
import 'package:frosty/widgets/accent_color_setting.dart';
import 'package:frosty/widgets/theme_selection_setting.dart';
import 'package:provider/provider.dart';
@ -40,10 +41,10 @@ class OnboardingSetup extends StatelessWidget {
children: [
TextSpan(
text: 'https://recent-messages.robotty.de/',
style: const TextStyle(
color: Colors.blue,
style: TextStyle(
color: context.colorScheme.primary,
decoration: TextDecoration.underline,
decorationColor: Colors.blue,
decorationColor: context.colorScheme.primary,
),
recognizer: TapGestureRecognizer()
..onTap = () => launchUrl(

View file

@ -42,7 +42,9 @@ class BlockedUsers extends StatelessWidget {
return ListTile(
title: Text(displayName),
trailing: TextButton(
style: TextButton.styleFrom(foregroundColor: Colors.red),
style: TextButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.error,
),
onPressed: () => authStore.showBlockDialog(
context,
targetUser: displayName,

View file

@ -320,10 +320,10 @@ class _ChatSettingsState extends State<ChatSettings> {
children: [
TextSpan(
text: 'https://recent-messages.robotty.de/',
style: const TextStyle(
color: Colors.blue,
style: TextStyle(
color: context.colorScheme.primary,
decoration: TextDecoration.underline,
decorationColor: Colors.blue,
decorationColor: context.colorScheme.primary,
),
recognizer: TapGestureRecognizer()
..onTap = () => launchUrl(

View file

@ -11,6 +11,90 @@ const kOverlayShadow = [
),
];
/// App-specific semantic colors that extend Material's [ColorScheme].
///
/// Colors that map to Material 3 roles use [ColorScheme] directly:
/// - Links → [ColorScheme.primary]
/// - Mentions → [ColorScheme.error]
/// - First-time chatters → [ColorScheme.tertiary]
///
/// Access via `Theme.of(context).extension<FrostyColors>()!` or the
/// `context.frostyColors` convenience extension.
class FrostyColors extends ThemeExtension<FrostyColors> {
final Color highlightedMessage;
final Color emoteOnly;
final Color followersOnly;
final Color slowMode;
final Color subOnly;
final Color uniqueMode;
final Color overlayOnSurface;
const FrostyColors({
required this.highlightedMessage,
required this.emoteOnly,
required this.followersOnly,
required this.slowMode,
required this.subOnly,
required this.uniqueMode,
required this.overlayOnSurface,
});
static const light = FrostyColors(
highlightedMessage: Color(0xff9146ff),
emoteOnly: Color(0xFFFFB74D),
followersOnly: Color(0xFFF44336),
slowMode: Color(0xFF2196F3),
subOnly: Color(0xFF4CAF50),
uniqueMode: Color(0xFFAB47BC),
overlayOnSurface: Colors.white,
);
static const dark = FrostyColors(
highlightedMessage: Color(0xff9146ff),
emoteOnly: Color(0xFFFFCC80),
followersOnly: Color(0xFFE57373),
slowMode: Color(0xFF64B5F6),
subOnly: Color(0xFF81C784),
uniqueMode: Color(0xFFBA68C8),
overlayOnSurface: Colors.white,
);
@override
FrostyColors copyWith({
Color? highlightedMessage,
Color? emoteOnly,
Color? followersOnly,
Color? slowMode,
Color? subOnly,
Color? uniqueMode,
Color? overlayOnSurface,
}) {
return FrostyColors(
highlightedMessage: highlightedMessage ?? this.highlightedMessage,
emoteOnly: emoteOnly ?? this.emoteOnly,
followersOnly: followersOnly ?? this.followersOnly,
slowMode: slowMode ?? this.slowMode,
subOnly: subOnly ?? this.subOnly,
uniqueMode: uniqueMode ?? this.uniqueMode,
overlayOnSurface: overlayOnSurface ?? this.overlayOnSurface,
);
}
@override
FrostyColors lerp(FrostyColors? other, double t) {
if (other is! FrostyColors) return this;
return FrostyColors(
highlightedMessage: Color.lerp(highlightedMessage, other.highlightedMessage, t)!,
emoteOnly: Color.lerp(emoteOnly, other.emoteOnly, t)!,
followersOnly: Color.lerp(followersOnly, other.followersOnly, t)!,
slowMode: Color.lerp(slowMode, other.slowMode, t)!,
subOnly: Color.lerp(subOnly, other.subOnly, t)!,
uniqueMode: Color.lerp(uniqueMode, other.uniqueMode, t)!,
overlayOnSurface: Color.lerp(overlayOnSurface, other.overlayOnSurface, t)!,
);
}
}
class FrostyThemes {
final Color colorSchemeSeed;
@ -38,6 +122,11 @@ class FrostyThemes {
const borderWidth = 0.5;
return ThemeData(
extensions: [
colorScheme.brightness == Brightness.dark
? FrostyColors.dark
: FrostyColors.light,
],
colorScheme: colorScheme,
fontFamily: 'Inter',
splashFactory: Platform.isIOS ? NoSplash.splashFactory : null,
@ -66,7 +155,7 @@ class FrostyThemes {
filled: true,
fillColor: colorScheme.surfaceContainerHighest.withValues(alpha: 0.6),
hintStyle: TextStyle(
color: colorScheme.onSurface.withValues(alpha: 0.6),
color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.normal,
),
border: OutlineInputBorder(

View file

@ -6,6 +6,7 @@ import 'package:frosty/apis/twitch_api.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:frosty/stores/global_assets_store.dart';
import 'package:frosty/theme.dart';
import 'package:provider/provider.dart';
/// Static utility class for orientation-related operations
@ -45,6 +46,9 @@ extension ContextExtensions on BuildContext {
/// Gets body small text color (commonly used for secondary text)
Color? get bodySmallColor => Theme.of(this).textTheme.bodySmall?.color;
/// Gets the app's custom semantic colors
FrostyColors get frostyColors => Theme.of(this).extension<FrostyColors>()!;
/// Gets the default text style
TextStyle get defaultTextStyle => DefaultTextStyle.of(this).style;

View file

@ -23,9 +23,9 @@ class AlertMessage extends StatelessWidget {
@override
Widget build(BuildContext context) {
final defaultColor = Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: vertical ? 0.6 : 1);
final colorScheme = Theme.of(context).colorScheme;
final defaultColor =
vertical ? colorScheme.onSurfaceVariant : colorScheme.onSurface;
final Widget widget;

View file

@ -59,7 +59,7 @@ class ExpandableSectionHeader extends StatelessWidget {
duration: const Duration(milliseconds: 200),
child: Icon(
Icons.keyboard_arrow_down,
color: context.colorScheme.onSurface.withValues(alpha: 0.6),
color: context.colorScheme.onSurfaceVariant,
size: 20,
),
),

View file

@ -44,9 +44,7 @@ class FrostyNotification extends StatelessWidget {
icon: const Icon(Icons.close_rounded, size: 20),
visualDensity: VisualDensity.compact,
tooltip: 'Dismiss',
color: Theme.of(
context,
).colorScheme.onSurfaceVariant.withValues(alpha: 0.6),
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
],
),

View file

@ -1,10 +1,9 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:frosty/cache_manager.dart';
import 'package:frosty/theme.dart';
import 'package:frosty/utils/context_extensions.dart';
import 'package:frosty/widgets/blurred_container.dart';
import 'package:photo_view/photo_view.dart';
import 'package:provider/provider.dart';
class FrostyPhotoViewDialog extends StatefulWidget {
final String imageUrl;
@ -180,7 +179,7 @@ class _FrostyPhotoViewDialogState extends State<FrostyPhotoViewDialog>
child: IconButton(
icon: Icon(
Icons.close,
color: context.watch<FrostyThemes>().dark.colorScheme.onSurface,
color: context.frostyColors.overlayOnSurface,
),
onPressed: Navigator.of(context).pop,
),
@ -205,11 +204,7 @@ class _FrostyPhotoViewDialogState extends State<FrostyPhotoViewDialog>
child: Text(
_isFullResolution ? 'View thumbnail' : 'View original',
style: TextStyle(
color: context
.watch<FrostyThemes>()
.dark
.colorScheme
.onSurface,
color: context.frostyColors.overlayOnSurface,
),
),
),

View file

@ -19,9 +19,7 @@ class LoadingIndicator extends StatelessWidget {
Text(
subtitle!,
style: TextStyle(
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.5),
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],

View file

@ -41,7 +41,7 @@ class SectionHeader extends StatelessWidget {
fontSize: fontSize ?? 13,
fontWeight: FontWeight.w600,
letterSpacing: 0.5,
color: context.colorScheme.onSurface.withValues(alpha: 0.6),
color: context.colorScheme.onSurfaceVariant,
),
),
],