Refresh themes and styling (#394)

* consolidate themes

* update and tweak styles, add border styling

* add borders between video and chat

* tweak heights

* improve reply threads
- taller new design
- can now tap parent's reply
- fix crash when tapping self

* tweak border colors

* tweak theme

* tweak theme

* tweak theme

* add accent color picker in settings

* overhaul colors and styles

* remove print

* add divider to chat user modal and revert height

* increase deleted message opacity

* revert highlight color

* tweak color

* tweak colors

* tweak padding

* tweak padding

* tweak placeholder color

* revert input fill color

* use error color
This commit is contained in:
Tommy Chow 2024-09-06 21:43:27 -07:00 • committed by GitHub
parent 29cd8ee455
commit f68de827cd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
41 changed files with 721 additions and 477 deletions

View file

@ -124,21 +124,23 @@ class _MyAppState extends State<MyApp> {
return Observer(
builder: (context) {
final settingsStore = context.read<SettingsStore>();
final themes = FrostyThemes();
final themes =
FrostyThemes(colorSchemeSeed: Color(settingsStore.accentColor));
return MaterialApp(
title: 'Frosty',
theme: themes.light,
darkTheme: settingsStore.themeType == ThemeType.black
? themes.black
: 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 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,
),
);
},
);

View file

@ -245,11 +245,17 @@ class _VideoChatState extends State<VideoChat> {
visible: settingsStore.fullScreenChatOverlay,
maintainState: true,
child: Theme(
data: FrostyThemes().dark,
data: FrostyThemes(
colorSchemeSeed: Color(settingsStore.accentColor),
).dark,
child: DefaultTextStyle(
style: DefaultTextStyle.of(context)
.style
.copyWith(color: Colors.white),
style: DefaultTextStyle.of(context).style.copyWith(
color: context
.watch<FrostyThemes>()
.dark
.colorScheme
.onSurface,
),
child: landscapeChat,
),
),
@ -281,15 +287,31 @@ class _VideoChatState extends State<VideoChat> {
if (settingsStore.showOverlay)
Row(
children: settingsStore.landscapeChatLeftSide
? [overlayChat, Expanded(child: overlay)]
: [Expanded(child: overlay), overlayChat],
? [
overlayChat,
const VerticalDivider(),
Expanded(child: overlay),
]
: [
Expanded(child: overlay),
const VerticalDivider(),
overlayChat,
],
),
],
)
: Row(
children: settingsStore.landscapeChatLeftSide
? [landscapeChat, Expanded(child: video)]
: [Expanded(child: video), landscapeChat],
? [
landscapeChat,
const VerticalDivider(),
Expanded(child: video),
]
: [
Expanded(child: video),
const VerticalDivider(),
landscapeChat,
],
)
: Column(
children: [appBar, Expanded(child: chat)],
@ -307,8 +329,10 @@ class _VideoChatState extends State<VideoChat> {
children: [
if (!settingsStore.showVideo)
appBar
else
else ...[
AspectRatio(aspectRatio: 16 / 9, child: video),
const Divider(),
],
Expanded(child: chat),
],
),

View file

@ -147,14 +147,14 @@ class ChatDetails extends StatelessWidget {
final children = [
const SectionHeader(
'Chat modes',
padding: EdgeInsets.fromLTRB(16, 0, 16, 4),
padding: EdgeInsets.fromLTRB(16, 0, 16, 8),
isFirst: true,
),
ListTile(
title: ChatModes(roomState: chatDetailsStore.roomState),
),
const SectionHeader(
'More',
padding: EdgeInsets.fromLTRB(16, 12, 16, 4),
),
ListTile(
leading: const Icon(Icons.people_outline),
@ -162,15 +162,12 @@ class ChatDetails extends StatelessWidget {
onTap: () => showModalBottomSheet(
isScrollControlled: true,
context: context,
builder: (context) => SizedBox(
height: MediaQuery.of(context).size.height * 0.8,
child: GestureDetector(
onTap: FocusScope.of(context).unfocus,
child: ChattersList(
chatDetailsStore: chatDetailsStore,
chatStore: chatStore,
userLogin: userLogin,
),
builder: (context) => GestureDetector(
onTap: FocusScope.of(context).unfocus,
child: ChattersList(
chatDetailsStore: chatDetailsStore,
chatStore: chatStore,
userLogin: userLogin,
),
),
),

View file

@ -10,6 +10,9 @@ class ChatModes extends StatelessWidget {
@override
Widget build(BuildContext context) {
final disabledColor =
Theme.of(context).colorScheme.onSurface.withOpacity(0.5);
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
@ -23,7 +26,7 @@ class ChatModes extends StatelessWidget {
roomState.emoteOnly != '0'
? Icons.emoji_emotions_rounded
: Icons.emoji_emotions_outlined,
color: roomState.emoteOnly != '0' ? Colors.yellow : Colors.grey,
color: roomState.emoteOnly != '0' ? Colors.yellow : disabledColor,
),
),
Tooltip(
@ -39,7 +42,7 @@ class ChatModes extends StatelessWidget {
roomState.followersOnly != '-1'
? Icons.favorite_rounded
: Icons.favorite_outline_rounded,
color: roomState.followersOnly != '-1' ? Colors.red : Colors.grey,
color: roomState.followersOnly != '-1' ? Colors.red : disabledColor,
),
),
Tooltip(
@ -51,7 +54,7 @@ class ChatModes extends StatelessWidget {
child: Text(
'R9K',
style: TextStyle(
color: roomState.r9k != '0' ? Colors.purple : Colors.grey,
color: roomState.r9k != '0' ? Colors.purple : disabledColor,
fontWeight: FontWeight.w500,
),
),
@ -66,7 +69,7 @@ class ChatModes extends StatelessWidget {
roomState.slowMode != '0'
? Icons.hourglass_top_rounded
: Icons.hourglass_empty_rounded,
color: roomState.slowMode != '0' ? Colors.blue : Colors.grey,
color: roomState.slowMode != '0' ? Colors.blue : disabledColor,
),
),
Tooltip(
@ -78,7 +81,7 @@ class ChatModes extends StatelessWidget {
roomState.subMode != '0'
? Icons.monetization_on_rounded
: Icons.monetization_on_outlined,
color: roomState.subMode != '0' ? Colors.green : Colors.grey,
color: roomState.subMode != '0' ? Colors.green : disabledColor,
),
),
],

View file

@ -7,6 +7,7 @@ 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/settings/stores/auth_store.dart';
import 'package:frosty/widgets/alert_message.dart';
import 'package:frosty/widgets/animated_scroll_border.dart';
import 'package:frosty/widgets/scroll_to_top_button.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
@ -30,153 +31,162 @@ class ChattersList extends StatefulWidget {
class _ChattersListState extends State<ChattersList> {
@override
Widget build(BuildContext context) {
return Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 12),
child: Observer(
builder: (context) {
return TextField(
controller: widget.chatDetailsStore.textController,
focusNode: widget.chatDetailsStore.textFieldFocusNode,
autocorrect: false,
decoration: InputDecoration(
prefixIcon: const Icon(Icons.filter_list_rounded),
hintText: 'Filter chatters',
suffixIcon: widget
.chatDetailsStore.textFieldFocusNode.hasFocus ||
widget.chatDetailsStore.filterText.isNotEmpty
? IconButton(
tooltip: widget.chatDetailsStore.filterText.isEmpty
? 'Cancel'
: 'Clear',
onPressed: () {
if (widget.chatDetailsStore.filterText.isEmpty) {
widget.chatDetailsStore.textFieldFocusNode
.unfocus();
}
widget.chatDetailsStore.textController.clear();
},
icon: const Icon(Icons.close_rounded),
)
: null,
),
);
},
),
),
Expanded(
child: RefreshIndicator.adaptive(
onRefresh: () async {
HapticFeedback.lightImpact();
setState(() {});
},
child: Stack(
alignment: AlignmentDirectional.bottomCenter,
children: [
Scrollbar(
controller: widget.chatDetailsStore.scrollController,
child: Observer(
builder: (context) {
return CustomScrollView(
physics: const AlwaysScrollableScrollPhysics(),
controller: widget.chatDetailsStore.scrollController,
slivers: [
if (widget.chatDetailsStore.filterText.isEmpty)
SliverPadding(
padding: const EdgeInsets.all(12),
sliver: SliverToBoxAdapter(
child: Text(
'${NumberFormat().format(widget.chatDetailsStore.chatUsers.length)} chatters found',
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 18.0,
),
),
),
),
if (widget.chatDetailsStore.chatUsers.isEmpty)
const SliverFillRemaining(
hasScrollBody: false,
child: AlertMessage(message: 'No chatters found'),
)
else if (widget
.chatDetailsStore.filteredUsers.isEmpty)
const SliverFillRemaining(
hasScrollBody: false,
child:
AlertMessage(message: 'No matching chatters'),
)
else
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => InkWell(
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 4,
),
child: Text(
widget.chatDetailsStore.filteredUsers
.elementAt(index),
style: const TextStyle(
fontWeight: FontWeight.w500,
),
),
),
onTap: () async {
final userInfo =
await context.read<TwitchApi>().getUser(
headers: context
.read<AuthStore>()
.headersTwitch,
userLogin: widget.chatDetailsStore
.filteredUsers
.elementAt(index),
);
if (!context.mounted) return;
showModalBottomSheet(
isScrollControlled: true,
context: context,
builder: (context) => ChatUserModal(
chatStore: widget.chatStore,
username: userInfo.login,
userId: userInfo.id,
displayName: userInfo.displayName,
),
);
},
),
childCount: widget
.chatDetailsStore.filteredUsers.length,
),
),
],
);
},
),
),
Observer(
builder: (context) => AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
switchInCurve: Curves.easeOut,
switchOutCurve: Curves.easeIn,
child: widget.chatDetailsStore.showJumpButton
? ScrollToTopButton(
scrollController:
widget.chatDetailsStore.scrollController,
return SizedBox(
height: MediaQuery.of(context).size.height * 0.8,
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 8),
child: Observer(
builder: (context) {
return TextField(
controller: widget.chatDetailsStore.textController,
focusNode: widget.chatDetailsStore.textFieldFocusNode,
autocorrect: false,
decoration: InputDecoration(
prefixIcon: const Icon(Icons.filter_list_rounded),
hintText: 'Filter chatters',
suffixIcon: widget
.chatDetailsStore.textFieldFocusNode.hasFocus ||
widget.chatDetailsStore.filterText.isNotEmpty
? IconButton(
tooltip: widget.chatDetailsStore.filterText.isEmpty
? 'Cancel'
: 'Clear',
onPressed: () {
if (widget.chatDetailsStore.filterText.isEmpty) {
widget.chatDetailsStore.textFieldFocusNode
.unfocus();
}
widget.chatDetailsStore.textController.clear();
},
icon: const Icon(Icons.close_rounded),
)
: null,
),
),
],
);
},
),
),
),
],
AnimatedScrollBorder(
scrollController: widget.chatDetailsStore.scrollController,
),
Expanded(
child: RefreshIndicator.adaptive(
onRefresh: () async {
HapticFeedback.lightImpact();
setState(() {});
},
child: Stack(
alignment: AlignmentDirectional.bottomCenter,
children: [
Scrollbar(
controller: widget.chatDetailsStore.scrollController,
child: Observer(
builder: (context) {
return CustomScrollView(
physics: const AlwaysScrollableScrollPhysics(),
controller: widget.chatDetailsStore.scrollController,
slivers: [
if (widget.chatDetailsStore.filterText.isEmpty)
SliverPadding(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
sliver: SliverToBoxAdapter(
child: Text(
'${NumberFormat().format(widget.chatDetailsStore.chatUsers.length)} chatters found',
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 18.0,
),
),
),
),
if (widget.chatDetailsStore.chatUsers.isEmpty)
const SliverFillRemaining(
hasScrollBody: false,
child:
AlertMessage(message: 'No chatters found'),
)
else if (widget
.chatDetailsStore.filteredUsers.isEmpty)
const SliverFillRemaining(
hasScrollBody: false,
child: AlertMessage(
message: 'No matching chatters',
),
)
else
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => InkWell(
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 4,
),
child: Text(
widget.chatDetailsStore.filteredUsers
.elementAt(index),
),
),
onTap: () async {
final userInfo = await context
.read<TwitchApi>()
.getUser(
headers: context
.read<AuthStore>()
.headersTwitch,
userLogin: widget
.chatDetailsStore.filteredUsers
.elementAt(index),
);
if (!context.mounted) return;
showModalBottomSheet(
isScrollControlled: true,
context: context,
builder: (context) => ChatUserModal(
chatStore: widget.chatStore,
username: userInfo.login,
userId: userInfo.id,
displayName: userInfo.displayName,
),
);
},
),
childCount: widget
.chatDetailsStore.filteredUsers.length,
),
),
],
);
},
),
),
Observer(
builder: (context) => AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
switchInCurve: Curves.easeOut,
switchOutCurve: Curves.easeIn,
child: widget.chatDetailsStore.showJumpButton
? ScrollToTopButton(
scrollController:
widget.chatDetailsStore.scrollController,
)
: null,
),
),
],
),
),
),
],
),
);
}

View file

@ -703,7 +703,7 @@ abstract class ChatStoreBase with Store {
// Set the new notification message and create a new timer that will dismiss it after 2 seconds.
_notification = notificationMessage;
_notificationTimer =
Timer(const Duration(seconds: 2), () => _notification = null);
Timer(const Duration(seconds: 3), () => _notification = null);
}
/// Updates the sleep timer with the given [duration].

View file

@ -5,6 +5,7 @@ import 'package:frosty/constants.dart';
import 'package:frosty/models/irc.dart';
import 'package:frosty/screens/channel/chat/details/chat_details.dart';
import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
import 'package:frosty/widgets/animated_scroll_border.dart';
import 'package:frosty/widgets/cached_image.dart';
class ChatBottomBar extends StatelessWidget {
@ -70,6 +71,7 @@ class ChatBottomBar extends StatelessWidget {
return Column(
children: [
AnimatedScrollBorder(scrollController: chatStore.scrollController),
if (chatStore.replyingToMessage != null) ...[
const Divider(),
ListTile(

View file

@ -8,19 +8,20 @@ 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/screens/settings/stores/auth_store.dart';
import 'package:frosty/theme.dart';
import 'package:provider/provider.dart';
class ChatMessage extends StatelessWidget {
final IRCMessage ircMessage;
final ChatStore chatStore;
final bool isModal;
final bool showReplyHeader;
const ChatMessage({
super.key,
required this.ircMessage,
required this.chatStore,
this.isModal = false,
this.showReplyHeader = true,
});
void onTapName(BuildContext context) {
@ -179,7 +180,7 @@ class ChatMessage extends StatelessWidget {
Widget? messageHeaderIcon;
Widget? messageHeader;
if (replyUser != null && replyBody != null) {
if (replyUser != null && replyBody != null && showReplyHeader) {
messageHeaderIcon = Icon(
Icons.chat_rounded,
size: messageHeaderIconSize,
@ -221,7 +222,7 @@ class ChatMessage extends StatelessWidget {
),
);
} else if (shouldHighlightMessage) {
highlightColor = FrostyThemes.purple;
highlightColor = const Color(0xff9146ff);
messageHeader = Text(
'Highlighted message',
style: TextStyle(
@ -265,7 +266,7 @@ class ChatMessage extends StatelessWidget {
final banDuration = ircMessage.tags['ban-duration'];
renderMessage = Opacity(
opacity: 0.4,
opacity: 0.5,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@ -315,7 +316,7 @@ class ChatMessage extends StatelessWidget {
break;
case Command.userNotice:
if (chatStore.settings.showUserNotices) {
highlightColor = FrostyThemes.purple;
highlightColor = const Color(0xff9146ff);
Widget? messageHeaderIcon;
Widget? messageHeader;

View file

@ -84,6 +84,7 @@ class _ChatUserModalState extends State<ChatUserModal> {
],
),
),
const Divider(),
Expanded(
child: Observer(
builder: (context) {

View file

@ -28,67 +28,73 @@ class ReplyThread extends StatelessWidget {
final replyUserLogin = selectedMessage.tags['reply-parent-user-login'];
final replyBody = selectedMessage.tags['reply-parent-msg-body'];
final replyName = getReadableName(replyDisplayName!, replyUserLogin!);
final replyName = replyUserLogin != null
? getReadableName(replyDisplayName!, replyUserLogin)
: replyDisplayName;
return Observer(
builder: (context) {
return MediaQuery(
data: MediaQuery.of(context).copyWith(
textScaler: TextScaler.linear(chatStore.settings.messageScale),
),
child: DefaultTextStyle(
style: DefaultTextStyle.of(context)
.style
.copyWith(fontSize: chatStore.settings.fontSize),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
const SectionHeader(
'Reply thread',
padding: EdgeInsets.fromLTRB(12, 0, 12, 8),
),
if (replyParent != null)
ChatMessage(
isModal: true,
ircMessage: replyParent,
chatStore: chatStore,
)
else
Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 8),
child: Text(
'Replies to @$replyName: $replyBody',
style: const TextStyle(
fontWeight: FontWeight.w500,
return SizedBox(
height: MediaQuery.of(context).size.height * 0.5,
child: Observer(
builder: (context) {
return MediaQuery(
data: MediaQuery.of(context).copyWith(
textScaler: TextScaler.linear(chatStore.settings.messageScale),
),
child: DefaultTextStyle(
style: DefaultTextStyle.of(context)
.style
.copyWith(fontSize: chatStore.settings.fontSize),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
const SectionHeader(
'Replies',
isFirst: true,
padding: EdgeInsets.fromLTRB(12, 0, 12, 8),
),
if (replyParent != null)
ChatMessage(
ircMessage: replyParent,
chatStore: chatStore,
)
else
Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 8),
child: Text(
'$replyName: $replyBody',
style: const TextStyle(
fontWeight: FontWeight.w500,
),
),
),
const Divider(),
Flexible(
child: ListView(
primary: false,
children: chatStore.messages
.where(
(message) =>
message.tags['reply-parent-msg-id'] ==
selectedMessage.tags['reply-parent-msg-id'],
)
.map(
(message) => ChatMessage(
isModal: true,
showReplyHeader: false,
ircMessage: message,
chatStore: chatStore,
),
)
.toList(),
),
),
Flexible(
child: ListView(
shrinkWrap: true,
primary: false,
children: chatStore.messages
.where(
(message) =>
message.tags['reply-parent-msg-id'] ==
selectedMessage.tags['reply-parent-msg-id'],
)
.map(
(message) => ChatMessage(
isModal: true,
ircMessage: message,
chatStore: chatStore,
),
)
.toList(),
),
),
],
],
),
),
),
);
},
);
},
),
);
}
}

View file

@ -8,10 +8,12 @@ import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
import 'package:frosty/screens/channel/video/video_bar.dart';
import 'package:frosty/screens/channel/video/video_store.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:frosty/theme.dart';
import 'package:frosty/utils.dart';
import 'package:frosty/widgets/section_header.dart';
import 'package:frosty/widgets/uptime.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
/// Creates a widget containing controls which enable interactions with an underlying [Video] widget.
class VideoOverlay extends StatelessWidget {
@ -30,11 +32,14 @@ class VideoOverlay extends StatelessWidget {
Widget build(BuildContext context) {
final orientation = MediaQuery.of(context).orientation;
final surfaceColor =
context.watch<FrostyThemes>().dark.colorScheme.onSurface;
final backButton = IconButton(
tooltip: 'Back',
icon: Icon(
Icons.adaptive.arrow_back_rounded,
color: Colors.white,
color: surfaceColor,
),
onPressed: Navigator.of(context).pop,
);
@ -49,13 +54,13 @@ class VideoOverlay extends StatelessWidget {
icon: videoStore.settingsStore.fullScreenChatOverlay
? const Icon(Icons.chat_rounded)
: const Icon(Icons.chat_outlined),
color: Colors.white,
color: surfaceColor,
),
);
final videoSettingsButton = IconButton(
icon: const Icon(Icons.settings),
color: Colors.white,
color: surfaceColor,
onPressed: () {
videoStore.updateStreamQualities();
showModalBottomSheet(
@ -106,19 +111,19 @@ class VideoOverlay extends StatelessWidget {
Observer(
builder: (context) => Text(
videoStore.latency ?? 'N/A',
style: const TextStyle(
color: Colors.white,
style: TextStyle(
color: surfaceColor,
fontWeight: FontWeight.w500,
fontFeatures: [FontFeature.tabularFigures()],
fontFeatures: const [FontFeature.tabularFigures()],
),
),
),
const SizedBox(
width: 8,
),
const Icon(
Icon(
Icons.speed_rounded,
color: Colors.white,
color: surfaceColor,
),
],
),
@ -129,9 +134,9 @@ class VideoOverlay extends StatelessWidget {
message: 'Refresh',
preferBelow: false,
child: IconButton(
icon: const Icon(
icon: Icon(
Icons.refresh_rounded,
color: Colors.white,
color: surfaceColor,
),
onPressed: videoStore.handleRefresh,
),
@ -147,7 +152,7 @@ class VideoOverlay extends StatelessWidget {
videoStore.settingsStore.fullScreen
? Icons.fullscreen_exit_rounded
: Icons.fullscreen_rounded,
color: Colors.white,
color: surfaceColor,
),
onPressed: () => videoStore.settingsStore.fullScreen =
!videoStore.settingsStore.fullScreen,
@ -160,9 +165,9 @@ class VideoOverlay extends StatelessWidget {
: 'Exit landscape mode',
preferBelow: false,
child: IconButton(
icon: const Icon(
icon: Icon(
Icons.screen_rotation_rounded,
color: Colors.white,
color: surfaceColor,
),
onPressed: () {
if (orientation == Orientation.portrait) {
@ -224,8 +229,8 @@ class VideoOverlay extends StatelessWidget {
Expanded(
child: VideoBar(
streamInfo: streamInfo,
titleTextColor: Colors.white,
subtitleTextColor: Colors.white,
titleTextColor: surfaceColor,
subtitleTextColor: surfaceColor,
subtitleTextWeight: FontWeight.w500,
),
),
@ -245,7 +250,7 @@ class VideoOverlay extends StatelessWidget {
videoStore.paused
? Icons.play_arrow_rounded
: Icons.pause_rounded,
color: Colors.white,
color: surfaceColor,
),
onPressed: videoStore.handlePausePlay,
),
@ -273,8 +278,8 @@ class VideoOverlay extends StatelessWidget {
const SizedBox(width: 4),
Uptime(
startTime: streamInfo.startedAt,
style: const TextStyle(
color: Colors.white,
style: TextStyle(
color: surfaceColor,
fontWeight: FontWeight.w500,
),
),
@ -289,36 +294,32 @@ class VideoOverlay extends StatelessWidget {
onTap: () => showModalBottomSheet(
isScrollControlled: true,
context: context,
builder: (context) => SizedBox(
height:
MediaQuery.of(context).size.height * 0.8,
child: GestureDetector(
onTap: FocusScope.of(context).unfocus,
child: ChattersList(
chatDetailsStore:
chatStore.chatDetailsStore,
chatStore: chatStore,
userLogin: streamInfo.userLogin,
),
builder: (context) => GestureDetector(
onTap: FocusScope.of(context).unfocus,
child: ChattersList(
chatDetailsStore:
chatStore.chatDetailsStore,
chatStore: chatStore,
userLogin: streamInfo.userLogin,
),
),
),
child: Row(
children: [
const Icon(
Icon(
Icons.visibility,
size: 14,
color: Colors.white,
color: surfaceColor,
),
const SizedBox(width: 4),
Text(
NumberFormat().format(
videoStore.streamInfo?.viewerCount,
),
style: const TextStyle(
color: Colors.white,
style: TextStyle(
color: surfaceColor,
fontWeight: FontWeight.w500,
fontFeatures: [
fontFeatures: const [
FontFeature.tabularFigures(),
],
),
@ -338,9 +339,9 @@ class VideoOverlay extends StatelessWidget {
message: 'Enter picture-in-picture',
preferBelow: false,
child: IconButton(
icon: const Icon(
icon: Icon(
Icons.picture_in_picture_alt_rounded,
color: Colors.white,
color: surfaceColor,
),
onPressed: videoStore.requestPictureInPicture,
),

View file

@ -61,6 +61,7 @@ class _HomeState extends State<Home> {
child: Scaffold(
appBar: AppBar(
centerTitle: false,
shape: const Border(),
title: Observer(
builder: (_) {
final titles = [
@ -73,19 +74,22 @@ class _HomeState extends State<Home> {
},
),
actions: [
IconButton(
tooltip: 'Settings',
icon: isLoggedIn
? ProfilePicture(
userLogin: _authStore.user.details!.login,
radius: 16,
)
: const Icon(Icons.settings_rounded),
onPressed: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
Settings(settingsStore: context.read<SettingsStore>()),
Padding(
padding: const EdgeInsets.only(right: 8),
child: IconButton(
tooltip: 'Settings',
icon: isLoggedIn
? ProfilePicture(
userLogin: _authStore.user.details!.login,
radius: 16,
)
: const Icon(Icons.settings_rounded),
onPressed: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
Settings(settingsStore: context.read<SettingsStore>()),
),
),
),
),
@ -111,31 +115,44 @@ class _HomeState extends State<Home> {
),
),
),
bottomNavigationBar: Observer(
builder: (_) => NavigationBar(
destinations: [
if (_authStore.isLoggedIn)
NavigationDestination(
icon: _homeStore.selectedIndex == 0
? const Icon(Icons.favorite_rounded)
: const Icon(Icons.favorite_border_rounded),
label: 'Following',
tooltip: 'Followed streams',
),
const NavigationDestination(
icon: Icon(Icons.arrow_upward_rounded),
label: 'Top',
tooltip: 'Top streams and categories',
bottomNavigationBar: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Divider(),
Observer(
builder: (_) => NavigationBar(
destinations: [
if (_authStore.isLoggedIn)
const NavigationDestination(
icon: Icon(
Icons.favorite_border_rounded,
),
selectedIcon: Icon(Icons.favorite_rounded),
label: 'Following',
tooltip: 'Following',
),
const NavigationDestination(
icon: Icon(
Icons.arrow_upward_rounded,
),
selectedIcon: Icon(Icons.arrow_upward_rounded),
label: 'Top',
tooltip: 'Top',
),
const NavigationDestination(
icon: Icon(
Icons.search_rounded,
),
selectedIcon: Icon(Icons.search_rounded),
label: 'Search',
tooltip: 'Search',
),
],
selectedIndex: _homeStore.selectedIndex,
onDestinationSelected: _homeStore.handleTap,
),
const NavigationDestination(
icon: Icon(Icons.search_rounded),
label: 'Search',
tooltip: 'Search for channels and categories',
),
],
selectedIndex: _homeStore.selectedIndex,
onDestinationSelected: _homeStore.handleTap,
),
),
],
),
),
);

View file

@ -7,6 +7,7 @@ import 'package:frosty/screens/home/search/search_results_channels.dart';
import 'package:frosty/screens/home/search/search_store.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/widgets/alert_message.dart';
import 'package:frosty/widgets/animated_scroll_border.dart';
import 'package:frosty/widgets/section_header.dart';
import 'package:provider/provider.dart';
@ -37,7 +38,7 @@ class _SearchState extends State<Search> {
Observer(
builder: (context) {
return Padding(
padding: const EdgeInsets.all(12),
padding: const EdgeInsets.all(16),
child: TextField(
controller: _searchStore.textEditingController,
focusNode: _searchStore.textFieldFocusNode,
@ -66,6 +67,7 @@ class _SearchState extends State<Search> {
);
},
),
AnimatedScrollBorder(scrollController: widget.scrollController),
Expanded(
child: Scrollbar(
controller: widget.scrollController,
@ -87,6 +89,7 @@ class _SearchState extends State<Search> {
const SectionHeader(
'History',
padding: EdgeInsets.zero,
isFirst: true,
),
TextButton(
onPressed: _searchStore.searchHistory.clear,
@ -129,7 +132,7 @@ class _SearchState extends State<Search> {
const SliverToBoxAdapter(
child: SectionHeader(
'Channels',
padding: EdgeInsets.fromLTRB(16, 12, 16, 8),
isFirst: true,
),
),
SearchResultsChannels(
@ -139,7 +142,6 @@ class _SearchState extends State<Search> {
const SliverToBoxAdapter(
child: SectionHeader(
'Categories',
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
),
),
SearchResultsCategories(searchStore: _searchStore),

View file

@ -6,6 +6,7 @@ import 'package:frosty/models/stream.dart';
import 'package:frosty/screens/channel/channel.dart';
import 'package:frosty/screens/channel/video/video_bar.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/theme.dart';
import 'package:frosty/utils.dart';
import 'package:frosty/widgets/block_report_modal.dart';
import 'package:frosty/widgets/cached_image.dart';
@ -41,9 +42,11 @@ class LargeStreamCard extends StatelessWidget {
final pixelRatio = MediaQuery.of(context).devicePixelRatio;
final thumbnailWidth = min((size.width * pixelRatio) ~/ 1, 1920);
final thumbnailHeight = min((thumbnailWidth * (9 / 16)).toInt(), 1080);
final surfaceColor =
context.watch<FrostyThemes>().dark.colorScheme.onSurface;
final thumbnail = ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(12)),
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: Stack(
alignment: Alignment.bottomLeft,
children: [
@ -68,7 +71,7 @@ class LargeStreamCard extends StatelessWidget {
) +
cacheUrlExtension,
placeholder: (context, url) => ColoredBox(
color: Colors.grey.shade900,
color: Theme.of(context).colorScheme.surfaceContainer,
child: const LoadingIndicator(),
),
useOldImageOnUrlChange: true,
@ -92,8 +95,8 @@ class LargeStreamCard extends StatelessWidget {
const SizedBox(width: 4),
Uptime(
startTime: streamInfo.startedAt,
style: const TextStyle(
color: Colors.white,
style: TextStyle(
color: surfaceColor,
fontWeight: FontWeight.w500,
),
),
@ -106,16 +109,16 @@ class LargeStreamCard extends StatelessWidget {
preferBelow: false,
child: Row(
children: [
const Icon(
Icon(
Icons.visibility,
size: 14,
color: Colors.white,
color: surfaceColor,
),
const SizedBox(width: 4),
Text(
NumberFormat().format(streamInfo.viewerCount),
style: const TextStyle(
color: Colors.white,
style: TextStyle(
color: surfaceColor,
fontWeight: FontWeight.w500,
),
),

View file

@ -6,6 +6,7 @@ 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/widgets/block_report_modal.dart';
import 'package:frosty/widgets/cached_image.dart';
@ -54,7 +55,7 @@ class StreamCard extends StatelessWidget {
) +
cacheUrlExtension,
placeholder: (context, url) => ColoredBox(
color: Colors.grey.shade900,
color: Theme.of(context).colorScheme.surfaceContainer,
child: const LoadingIndicator(),
),
useOldImageOnUrlChange: true,
@ -97,10 +98,10 @@ class StreamCard extends StatelessWidget {
margin: const EdgeInsets.all(4),
child: Uptime(
startTime: streamInfo.startedAt,
style: const TextStyle(
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: Colors.white,
color: context.watch<FrostyThemes>().dark.colorScheme.onSurface,
),
),
),

View file

@ -9,6 +9,7 @@ import 'package:frosty/screens/home/top/categories/category_card.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:frosty/widgets/alert_message.dart';
import 'package:frosty/widgets/animated_scroll_border.dart';
import 'package:frosty/widgets/loading_indicator.dart';
import 'package:frosty/widgets/scroll_to_top_button.dart';
import 'package:provider/provider.dart';
@ -136,6 +137,10 @@ class _StreamsListState extends State<StreamsList>
category: _listStore.categoryDetails!,
isTappable: false,
),
if (_listStore.scrollController != null)
AnimatedScrollBorder(
scrollController: _listStore.scrollController!,
),
Expanded(
child: Scrollbar(
controller: _listStore.scrollController,

View file

@ -6,6 +6,7 @@ import 'package:frosty/screens/home/top/categories/categories_store.dart';
import 'package:frosty/screens/home/top/categories/category_card.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/widgets/alert_message.dart';
import 'package:frosty/widgets/animated_scroll_border.dart';
import 'package:frosty/widgets/loading_indicator.dart';
import 'package:provider/provider.dart';
@ -99,23 +100,32 @@ class _CategoriesState extends State<Categories>
);
}
return Scrollbar(
controller: widget.scrollController,
child: ListView.builder(
physics: const AlwaysScrollableScrollPhysics(),
controller: widget.scrollController,
itemCount: _categoriesStore.categories.length,
itemBuilder: (context, index) {
if (index > _categoriesStore.categories.length - 10 &&
_categoriesStore.hasMore) {
_categoriesStore.getCategories();
}
return CategoryCard(
key: ValueKey(_categoriesStore.categories[index].id),
category: _categoriesStore.categories[index],
);
},
),
return Column(
children: [
AnimatedScrollBorder(
scrollController: widget.scrollController,
),
Expanded(
child: Scrollbar(
controller: widget.scrollController,
child: ListView.builder(
physics: const AlwaysScrollableScrollPhysics(),
controller: widget.scrollController,
itemCount: _categoriesStore.categories.length,
itemBuilder: (context, index) {
if (index > _categoriesStore.categories.length - 10 &&
_categoriesStore.hasMore) {
_categoriesStore.getCategories();
}
return CategoryCard(
key: ValueKey(_categoriesStore.categories[index].id),
category: _categoriesStore.categories[index],
);
},
),
),
),
],
);
},
),

View file

@ -51,7 +51,7 @@ class CategoryCard extends StatelessWidget {
'${artWidth}x$artHeight.jpg',
),
placeholder: (context, url) => ColoredBox(
color: Colors.grey.shade900,
color: Theme.of(context).colorScheme.surfaceContainer,
child: const LoadingIndicator(),
),
),

View file

@ -1,7 +1,6 @@
import 'package:flutter/material.dart';
import 'package:frosty/screens/home/stream_list/stream_list_store.dart';
import 'package:frosty/screens/home/stream_list/streams_list.dart';
import 'package:frosty/widgets/app_bar.dart';
/// A widget that displays a list of streams under the provided [categoryId].
class CategoryStreams extends StatelessWidget {
@ -16,8 +15,13 @@ class CategoryStreams extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: const FrostyAppBar(
title: SizedBox(),
appBar: AppBar(
leading: IconButton(
tooltip: 'Back',
icon: Icon(Icons.adaptive.arrow_back_rounded),
onPressed: Navigator.of(context).pop,
),
shape: const Border(),
),
body: StreamsList(
listType: ListType.category,

View file

@ -27,9 +27,9 @@ class ProfileCard extends StatelessWidget {
builder: (context) {
if (authStore.error != null) {
return ListTile(
leading: const Icon(
leading: Icon(
Icons.error_outline_rounded,
color: Colors.red,
color: Theme.of(context).colorScheme.error,
),
title: const Text('Failed to connect'),
trailing: FilledButton.tonal(

View file

@ -30,12 +30,15 @@ class _ChatSettingsState extends State<ChatSettings> {
return Observer(
builder: (context) => ListView(
padding: const EdgeInsets.only(top: 16),
children: [
const SectionHeader('Message sizing'),
const SectionHeader(
'Message sizing',
isFirst: true,
),
ExpansionTile(
title: const Text(
'Preview',
style: TextStyle(fontWeight: FontWeight.w500),
),
children: [
Container(
@ -141,7 +144,7 @@ class _ChatSettingsState extends State<ChatSettings> {
divisions: 15,
onChanged: (newValue) => settingsStore.fontSize = newValue,
),
const SectionHeader('Message appearance', showDivider: true),
const SectionHeader('Message appearance'),
SettingsListSwitch(
title: 'Use readable name colors',
subtitle: const Text(
@ -172,7 +175,7 @@ class _ChatSettingsState extends State<ChatSettings> {
onChanged: (newValue) => settingsStore.timestampType =
TimestampType.values[timestampNames.indexOf(newValue)],
),
const SectionHeader('Delay and latency', showDivider: true),
const SectionHeader('Delay and latency'),
SettingsListSwitch(
title: 'Sync message delay with stream latency (experimental)',
value: settingsStore.autoSyncChatDelay,
@ -189,7 +192,7 @@ class _ChatSettingsState extends State<ChatSettings> {
divisions: 30,
onChanged: (newValue) => settingsStore.chatDelay = newValue,
),
const SectionHeader('Alerts', showDivider: true),
const SectionHeader('Alerts'),
SettingsListSwitch(
title: 'Highlight first time chatters',
value: settingsStore.highlightFirstTimeChatter,
@ -204,7 +207,7 @@ class _ChatSettingsState extends State<ChatSettings> {
value: settingsStore.showUserNotices,
onChanged: (newValue) => settingsStore.showUserNotices = newValue,
),
const SectionHeader('Layout', showDivider: true),
const SectionHeader('Layout'),
SettingsListSwitch(
title: 'Show bottom bar',
value: settingsStore.showBottomBar,
@ -225,7 +228,7 @@ class _ChatSettingsState extends State<ChatSettings> {
onChanged: (newValue) =>
settingsStore.chatNotificationsOnBottom = newValue,
),
const SectionHeader('Landscape mode', showDivider: true),
const SectionHeader('Landscape mode'),
SettingsListSwitch(
title: 'Move chat left',
value: settingsStore.landscapeChatLeftSide,
@ -271,7 +274,7 @@ class _ChatSettingsState extends State<ChatSettings> {
onChanged: (newValue) =>
settingsStore.fullScreenChatOverlayOpacity = newValue,
),
const SectionHeader('Sleep', showDivider: true),
const SectionHeader('Sleep'),
SettingsListSwitch(
title: 'Prevent sleep in chat-only mode',
subtitle: const Text(
@ -281,7 +284,7 @@ class _ChatSettingsState extends State<ChatSettings> {
onChanged: (newValue) =>
settingsStore.chatOnlyPreventSleep = newValue,
),
const SectionHeader('Autocomplete', showDivider: true),
const SectionHeader('Autocomplete'),
SettingsListSwitch(
title: 'Show autocomplete bar',
subtitle: const Text(
@ -290,7 +293,7 @@ class _ChatSettingsState extends State<ChatSettings> {
value: settingsStore.autocomplete,
onChanged: (newValue) => settingsStore.autocomplete = newValue,
),
const SectionHeader('Emotes and badges', showDivider: true),
const SectionHeader('Emotes and badges'),
SettingsListSwitch(
title: 'Show Twitch emotes',
value: settingsStore.showTwitchEmotes,
@ -326,7 +329,7 @@ class _ChatSettingsState extends State<ChatSettings> {
value: settingsStore.showFFZBadges,
onChanged: (newValue) => settingsStore.showFFZBadges = newValue,
),
const SectionHeader('Recent messages', showDivider: true),
const SectionHeader('Recent messages'),
SettingsListSwitch(
title: 'Show historical recent messages',
subtitle: Text.rich(

View file

@ -1,8 +1,10 @@
import 'package:flutter/material.dart';
import 'package:flutter_colorpicker/flutter_colorpicker.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:frosty/screens/settings/widgets/settings_list_select.dart';
import 'package:frosty/screens/settings/widgets/settings_list_switch.dart';
import 'package:frosty/widgets/dialog.dart';
import 'package:frosty/widgets/section_header.dart';
class GeneralSettings extends StatelessWidget {
@ -14,16 +16,63 @@ class GeneralSettings extends StatelessWidget {
Widget build(BuildContext context) {
return Observer(
builder: (context) => ListView(
padding: const EdgeInsets.only(top: 16),
children: [
const SectionHeader('Display'),
const SectionHeader(
'Theme',
isFirst: true,
),
SettingsListSelect(
title: 'Theme',
selectedOption: themeNames[settingsStore.themeType.index],
options: themeNames,
onChanged: (newTheme) => settingsStore.themeType =
ThemeType.values[themeNames.indexOf(newTheme)],
),
const SectionHeader('Stream card', showDivider: true),
ListTile(
title: const Text('Accent color'),
trailing: IconButton(
icon: DecoratedBox(
position: DecorationPosition.foreground,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: Theme.of(context).colorScheme.onSurface,
width: 2,
),
),
child: CircleAvatar(
backgroundColor: Color(settingsStore.accentColor),
radius: 16,
),
),
onPressed: () {
showDialog(
context: context,
builder: (context) => FrostyDialog(
title: 'Accent color',
content: SingleChildScrollView(
child: ColorPicker(
pickerColor: Color(settingsStore.accentColor),
onColorChanged: (newColor) =>
settingsStore.accentColor = newColor.value,
enableAlpha: false,
pickerAreaBorderRadius:
const BorderRadius.all(Radius.circular(8)),
labelTypes: const [],
),
),
actions: [
FilledButton(
onPressed: () => Navigator.pop(context),
child: const Text('Done'),
),
],
),
);
},
),
),
const SectionHeader('Stream card'),
SettingsListSwitch(
title: 'Use large stream card',
value: settingsStore.largeStreamCard,
@ -34,7 +83,7 @@ class GeneralSettings extends StatelessWidget {
value: settingsStore.showThumbnails,
onChanged: (newValue) => settingsStore.showThumbnails = newValue,
),
const SectionHeader('Links', showDivider: true),
const SectionHeader('Links'),
SettingsListSwitch(
title: 'Open links in external browser',
value: settingsStore.launchUrlExternal,

View file

@ -37,25 +37,32 @@ class Settings extends StatelessWidget {
),
icon: const Icon(SimpleIcons.buymeacoffee),
),
IconButton(
tooltip: 'View source on GitHub',
onPressed: () => launchUrl(
Uri.parse('https://github.com/tommyxchow/frosty'),
mode: settingsStore.launchUrlExternal
? LaunchMode.externalApplication
: LaunchMode.inAppBrowserView,
Padding(
padding: const EdgeInsets.only(right: 8),
child: IconButton(
tooltip: 'View source on GitHub',
onPressed: () => launchUrl(
Uri.parse('https://github.com/tommyxchow/frosty'),
mode: settingsStore.launchUrlExternal
? LaunchMode.externalApplication
: LaunchMode.inAppBrowserView,
),
icon: const Icon(SimpleIcons.github),
),
icon: const Icon(SimpleIcons.github),
),
],
),
body: SafeArea(
bottom: false,
child: ListView(
padding: const EdgeInsets.only(top: 16),
children: [
const SectionHeader('Profile'),
const SectionHeader(
'Profile',
isFirst: true,
),
ProfileCard(authStore: context.read<AuthStore>()),
const SectionHeader('Options', showDivider: true),
const SectionHeader('Customize'),
SettingsTileRoute(
leading: const Icon(Icons.settings_outlined),
title: 'General',
@ -71,7 +78,7 @@ class Settings extends StatelessWidget {
title: 'Chat',
child: ChatSettings(settingsStore: settingsStore),
),
const SectionHeader('Other', showDivider: true),
const SectionHeader('Other'),
OtherSettings(settingsStore: settingsStore),
],
),

View file

@ -16,6 +16,7 @@ abstract class _SettingsStoreBase with Store {
// * General Settings
// Theme defaults
static const defaultThemeType = ThemeType.system;
static const defaultAccentColor = 0xff9146ff;
// Stream card defaults
static const defaultShowThumbnails = true;
@ -29,6 +30,10 @@ abstract class _SettingsStoreBase with Store {
@observable
var themeType = defaultThemeType;
@JsonKey(defaultValue: defaultAccentColor)
@observable
var accentColor = defaultAccentColor;
// Stream card options
@JsonKey(defaultValue: defaultShowThumbnails)
@observable
@ -46,6 +51,7 @@ abstract class _SettingsStoreBase with Store {
@action
void resetGeneralSettings() {
themeType = defaultThemeType;
accentColor = defaultAccentColor;
largeStreamCard = defaultLargeStreamCard;
showThumbnails = defaultShowThumbnails;
@ -379,13 +385,12 @@ abstract class _SettingsStoreBase with Store {
}
}
const themeNames = ['System', 'Light', 'Dark', 'Black'];
const themeNames = ['System', 'Light', 'Dark'];
enum ThemeType {
system,
light,
dark,
black,
}
const timestampNames = ['Disabled', '12-hour', '24-hour'];

View file

@ -11,6 +11,7 @@ SettingsStore _$SettingsStoreFromJson(Map<String, dynamic> json) =>
..themeType = $enumDecodeNullable(_$ThemeTypeEnumMap, json['themeType'],
unknownValue: ThemeType.system) ??
ThemeType.system
..accentColor = (json['accentColor'] as num?)?.toInt() ?? 4287710975
..showThumbnails = json['showThumbnails'] as bool? ?? true
..largeStreamCard = json['largeStreamCard'] as bool? ?? false
..launchUrlExternal = json['launchUrlExternal'] as bool? ?? false
@ -71,6 +72,7 @@ SettingsStore _$SettingsStoreFromJson(Map<String, dynamic> json) =>
Map<String, dynamic> _$SettingsStoreToJson(SettingsStore instance) =>
<String, dynamic>{
'themeType': _$ThemeTypeEnumMap[instance.themeType]!,
'accentColor': instance.accentColor,
'showThumbnails': instance.showThumbnails,
'largeStreamCard': instance.largeStreamCard,
'launchUrlExternal': instance.launchUrlExternal,
@ -122,7 +124,6 @@ const _$ThemeTypeEnumMap = {
ThemeType.system: 'system',
ThemeType.light: 'light',
ThemeType.dark: 'dark',
ThemeType.black: 'black',
};
const _$TimestampTypeEnumMap = {
@ -161,6 +162,22 @@ mixin _$SettingsStore on _SettingsStoreBase, Store {
});
}
late final _$accentColorAtom =
Atom(name: '_SettingsStoreBase.accentColor', context: context);
@override
int get accentColor {
_$accentColorAtom.reportRead();
return super.accentColor;
}
@override
set accentColor(int value) {
_$accentColorAtom.reportWrite(value, super.accentColor, () {
super.accentColor = value;
});
}
late final _$showThumbnailsAtom =
Atom(name: '_SettingsStoreBase.showThumbnails', context: context);
@ -951,6 +968,7 @@ mixin _$SettingsStore on _SettingsStoreBase, Store {
String toString() {
return '''
themeType: ${themeType},
accentColor: ${accentColor},
showThumbnails: ${showThumbnails},
largeStreamCard: ${largeStreamCard},
launchUrlExternal: ${launchUrlExternal},

View file

@ -17,8 +17,12 @@ class VideoSettings extends StatelessWidget {
Widget build(BuildContext context) {
return Observer(
builder: (context) => ListView(
padding: const EdgeInsets.only(top: 16),
children: [
const SectionHeader('Player'),
const SectionHeader(
'Player',
isFirst: true,
),
SettingsListSwitch(
title: 'Enable video',
value: settingsStore.showVideo,
@ -36,7 +40,7 @@ class VideoSettings extends StatelessWidget {
value: settingsStore.showLatency,
onChanged: (newValue) => settingsStore.showLatency = newValue,
),
const SectionHeader('Overlay', showDivider: true),
const SectionHeader('Overlay'),
SettingsListSwitch(
title: 'Use custom video overlay',
subtitle: const Text(

View file

@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
/// A custom-styled adaptive [ListTile] with options to select.
class SettingsListSelect extends StatelessWidget {
final String title;
final String? title;
final String? subtitle;
final String selectedOption;
final List<String> options;
@ -10,7 +10,7 @@ class SettingsListSelect extends StatelessWidget {
const SettingsListSelect({
super.key,
required this.title,
this.title,
this.subtitle,
required this.selectedOption,
required this.options,
@ -20,9 +20,11 @@ class SettingsListSelect extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ListTile(
title: Text(title),
title: title != null ? Text(title!) : null,
subtitle: Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
padding: title != null
? const EdgeInsets.symmetric(vertical: 8)
: EdgeInsets.zero,
child: SegmentedButton(
style: const ButtonStyle(visualDensity: VisualDensity.compact),
segments: options

View file

@ -1,4 +1,3 @@
import 'package:flutter/material.dart';
/// A custom-styled adaptive [Slider].
@ -24,37 +23,37 @@ class SettingsListSlider extends StatelessWidget {
this.divisions,
});
static const _textStyle = TextStyle(fontWeight: FontWeight.w500);
@override
Widget build(BuildContext context) {
return ListTile(
contentPadding:
const EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0),
title: Row(
children: [
Text(title, style: _textStyle),
Text(title),
const Spacer(),
Text(
trailing,
style: _textStyle.copyWith(
fontFeatures: [const FontFeature.tabularFigures()],
style: const TextStyle(
fontFeatures: [FontFeature.tabularFigures()],
),
),
],
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Slider.adaptive(
value: value,
min: min,
max: max,
divisions: divisions,
onChanged: onChanged,
),
if (subtitle != null) Text(subtitle!),
],
subtitle: Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Slider.adaptive(
value: value,
min: min,
max: max,
divisions: divisions,
onChanged: onChanged,
),
if (subtitle != null) Text(subtitle!),
],
),
),
);
}

View file

@ -18,7 +18,6 @@ class SettingsListSwitch extends StatelessWidget {
@override
Widget build(BuildContext context) {
return SwitchListTile.adaptive(
isThreeLine: subtitle != null,
title: Text(title),
subtitle: subtitle,
value: value,

View file

@ -3,22 +3,28 @@ import 'dart:io';
import 'package:flutter/material.dart';
class FrostyThemes {
static const gray = Color.fromRGBO(18, 18, 18, 1);
static const purple = Color(0xff9146ff);
final Color colorSchemeSeed;
const FrostyThemes({required this.colorSchemeSeed});
ThemeData createBaseTheme({
required Brightness brightness,
required Color colorSchemeSeed,
Color? backgroundColor,
}) {
final secondaryBackground = brightness == Brightness.light
? Colors.grey.shade200
: Colors.grey.shade800;
final colorScheme = ColorScheme.fromSeed(
seedColor: colorSchemeSeed,
brightness: brightness,
);
final borderColor = colorScheme.outlineVariant;
const borderWidth = 0.5;
return ThemeData(
fontFamily: 'Inter',
brightness: brightness,
colorSchemeSeed: colorSchemeSeed,
colorScheme: colorScheme,
splashFactory: Platform.isIOS ? NoSplash.splashFactory : null,
scaffoldBackgroundColor: backgroundColor,
bottomSheetTheme: BottomSheetThemeData(
@ -34,25 +40,31 @@ class FrostyThemes {
elevation: 0,
backgroundColor: backgroundColor,
surfaceTintColor: backgroundColor,
shape: Border(
bottom: BorderSide(color: borderColor, width: borderWidth),
),
),
inputDecorationTheme: InputDecorationTheme(
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
border: OutlineInputBorder(
borderRadius: const BorderRadius.all(Radius.circular(100)),
borderSide: BorderSide(color: secondaryBackground),
filled: true,
fillColor: colorScheme.surfaceContainer,
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(100)),
),
enabledBorder: OutlineInputBorder(
borderRadius: const BorderRadius.all(Radius.circular(100)),
borderSide: BorderSide(color: secondaryBackground),
enabledBorder: const OutlineInputBorder(
borderSide: BorderSide.none,
borderRadius: BorderRadius.all(Radius.circular(100)),
),
disabledBorder: OutlineInputBorder(
borderRadius: const BorderRadius.all(Radius.circular(100)),
borderSide: BorderSide(color: secondaryBackground),
disabledBorder: const OutlineInputBorder(
borderSide: BorderSide.none,
borderRadius: BorderRadius.all(Radius.circular(100)),
),
),
navigationBarTheme: NavigationBarThemeData(
elevation: 0,
backgroundColor: backgroundColor,
height: 64,
labelBehavior: NavigationDestinationLabelBehavior.alwaysHide,
),
tabBarTheme: const TabBarTheme(
dividerColor: Colors.transparent,
@ -60,28 +72,29 @@ class FrostyThemes {
),
tooltipTheme: TooltipThemeData(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
margin: const EdgeInsets.symmetric(horizontal: 8),
margin: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: const BorderRadius.all(Radius.circular(8)),
border: Border.all(color: secondaryBackground),
border: Border.all(color: borderColor, width: borderWidth),
),
textStyle: TextStyle(
color: brightness == Brightness.dark ? Colors.white : Colors.black,
color: colorScheme.onSurface,
),
),
snackBarTheme: SnackBarThemeData(
showCloseIcon: true,
backgroundColor: backgroundColor,
shape: RoundedRectangleBorder(
side: BorderSide(color: secondaryBackground),
side: BorderSide(color: borderColor),
borderRadius: const BorderRadius.all(Radius.circular(8)),
),
behavior: SnackBarBehavior.floating,
),
dividerTheme: const DividerThemeData(
thickness: 0.5,
space: 0.5,
dividerTheme: DividerThemeData(
thickness: borderWidth,
space: borderWidth,
color: borderColor,
),
textTheme: const TextTheme(
// Used in alert dialog title.
@ -112,7 +125,6 @@ class FrostyThemes {
// Used in list tile title.
bodyLarge: TextStyle(
letterSpacing: 0,
fontWeight: FontWeight.w500,
),
bodyMedium: TextStyle(
letterSpacing: 0,
@ -127,8 +139,8 @@ class FrostyThemes {
ThemeData get light {
final theme = createBaseTheme(
brightness: Brightness.light,
colorSchemeSeed: purple,
backgroundColor: Colors.white,
colorSchemeSeed: colorSchemeSeed,
backgroundColor: const Color.fromRGBO(248, 248, 248, 1),
);
return theme;
@ -137,17 +149,7 @@ class FrostyThemes {
ThemeData get dark {
final theme = createBaseTheme(
brightness: Brightness.dark,
colorSchemeSeed: purple,
backgroundColor: gray,
);
return theme;
}
ThemeData get black {
final theme = createBaseTheme(
brightness: Brightness.dark,
colorSchemeSeed: purple,
colorSchemeSeed: colorSchemeSeed,
backgroundColor: Colors.black,
);

View file

@ -15,21 +15,23 @@ class AlertMessage extends StatelessWidget {
@override
Widget build(BuildContext context) {
final defaultColor =
Theme.of(context).colorScheme.onSurface.withOpacity(0.5);
return Row(
mainAxisAlignment:
centered ? MainAxisAlignment.center : MainAxisAlignment.start,
children: [
Icon(
Icons.info_outline_rounded,
color: color ?? Colors.grey,
color: color ?? defaultColor,
),
const SizedBox(width: 8),
Flexible(
child: Text(
message,
style: TextStyle(
fontWeight: FontWeight.w500,
color: color ?? Colors.grey,
color: color ?? defaultColor,
),
),
),

View file

@ -0,0 +1,50 @@
import 'package:flutter/material.dart';
class AnimatedScrollBorder extends StatefulWidget {
final ScrollController scrollController;
const AnimatedScrollBorder({
super.key,
required this.scrollController,
});
@override
State<AnimatedScrollBorder> createState() => _AnimatedScrollBorderState();
}
class _AnimatedScrollBorderState extends State<AnimatedScrollBorder> {
bool _isScrolled = false;
@override
void initState() {
super.initState();
widget.scrollController.addListener(_updateScrollState);
}
@override
void dispose() {
widget.scrollController.removeListener(_updateScrollState);
super.dispose();
}
void _updateScrollState() {
setState(() {
_isScrolled = widget.scrollController.offset > 0;
});
}
@override
Widget build(BuildContext context) {
return AnimatedSwitcher(
switchInCurve: Curves.easeOut,
switchOutCurve: Curves.easeIn,
duration: const Duration(milliseconds: 200),
child: _isScrolled
? const Divider()
: Divider(
key: ValueKey(_isScrolled),
color: Colors.transparent,
),
);
}
}

View file

@ -17,7 +17,7 @@ class FrostyDialog extends StatelessWidget {
@override
Widget build(BuildContext context) {
return AlertDialog.adaptive(
return AlertDialog(
title: Text(
title,
// style: const TextStyle(fontWeight: FontWeight.bold),

View file

@ -18,9 +18,8 @@ class LoadingIndicator extends StatelessWidget {
SizedBox(height: spacing),
Text(
subtitle!,
style: const TextStyle(
fontWeight: FontWeight.w500,
color: Colors.grey,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.5),
),
),
],

View file

@ -16,28 +16,25 @@ class FrostyNotification extends StatelessWidget {
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: Colors.grey.shade900,
color: Theme.of(context).colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(8),
),
margin: const EdgeInsets.all(10),
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.all(10),
padding: const EdgeInsets.all(8),
child: Row(
children: [
const Icon(
Icons.info_outline_rounded,
color: Colors.white,
),
const SizedBox(width: 10),
const SizedBox(width: 8),
Expanded(
child: Text(
message,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w500,
overflow: TextOverflow.ellipsis,
),
),

View file

@ -1,6 +1,8 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:frosty/theme.dart';
import 'package:photo_view/photo_view.dart';
import 'package:provider/provider.dart';
class FrostyPhotoViewDialog extends StatefulWidget {
final String imageUrl;
@ -34,7 +36,10 @@ class _FrostyPhotoViewDialogState extends State<FrostyPhotoViewDialog> {
),
),
IconButton(
icon: const Icon(Icons.close, color: Colors.white),
icon: Icon(
Icons.close,
color: context.watch<FrostyThemes>().dark.colorScheme.onSurface,
),
onPressed: Navigator.of(context).pop,
),
],

View file

@ -19,6 +19,8 @@ class ProfilePicture extends StatelessWidget {
Widget build(BuildContext context) {
final diameter = radius * 2;
final placeholderColor = Theme.of(context).colorScheme.surfaceContainer;
return ClipOval(
child: FutureBuilder(
future: context.read<TwitchApi>().getUser(
@ -32,10 +34,10 @@ class ProfilePicture extends StatelessWidget {
height: diameter,
imageUrl: snapshot.data!.profileImageUrl,
placeholder: (context, url) =>
ColoredBox(color: Colors.grey.shade900),
ColoredBox(color: placeholderColor),
)
: Container(
color: Colors.grey.shade900,
color: placeholderColor,
width: diameter,
height: diameter,
);

View file

@ -21,7 +21,7 @@ class ScrollToTopButton extends StatelessWidget {
),
style: ElevatedButton.styleFrom(
shape: const CircleBorder(),
padding: const EdgeInsets.all(16),
padding: const EdgeInsets.all(12),
),
child: const Icon(Icons.arrow_upward_rounded),
),

View file

@ -4,34 +4,37 @@ class SectionHeader extends StatelessWidget {
final String text;
final EdgeInsets? padding;
final double? fontSize;
final bool showDivider;
final bool isFirst;
const SectionHeader(
this.text, {
super.key,
this.padding,
this.fontSize,
this.showDivider = false,
this.isFirst = false,
});
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (showDivider) const Divider(),
Padding(
padding: padding ?? const EdgeInsets.fromLTRB(16, 16, 16, 4),
child: Text(
return Padding(
padding: padding ?? const EdgeInsets.fromLTRB(16, 0, 16, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (!isFirst) ...[
const Divider(),
const SizedBox(height: 16),
],
Text(
text,
style: TextStyle(
fontSize: fontSize ?? 16,
fontWeight: FontWeight.w600,
),
),
),
],
],
),
);
}
}

View file

@ -411,6 +411,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.4.1"
flutter_colorpicker:
dependency: "direct main"
description:
name: flutter_colorpicker
sha256: "969de5f6f9e2a570ac660fb7b501551451ea2a1ab9e2097e89475f60e07816ea"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
flutter_launcher_icons:
dependency: "direct dev"
description:
@ -1247,10 +1255,10 @@ packages:
dependency: transitive
description:
name: vm_service
sha256: f652077d0bdf60abe4c1f6377448e8655008eef28f128bc023f7b5e8dfeb48fc
sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d"
url: "https://pub.dev"
source: hosted
version: "14.2.4"
version: "14.2.5"
wakelock_plus:
dependency: "direct main"
description:

View file

@ -66,6 +66,7 @@ dependencies:
webview_flutter_wkwebview: ^3.4.3
photo_view: ^0.15.0
flutter_markdown: ^0.7.3+1
flutter_colorpicker: ^1.1.0
dev_dependencies:
flutter_test: