diff --git a/lib/main.dart b/lib/main.dart index c72c602..3760fec 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -124,21 +124,23 @@ class _MyAppState extends State { return Observer( builder: (context) { final settingsStore = context.read(); - 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( + 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, + ), ); }, ); diff --git a/lib/screens/channel/channel.dart b/lib/screens/channel/channel.dart index a0e0f3b..17db252 100644 --- a/lib/screens/channel/channel.dart +++ b/lib/screens/channel/channel.dart @@ -245,11 +245,17 @@ class _VideoChatState extends State { 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() + .dark + .colorScheme + .onSurface, + ), child: landscapeChat, ), ), @@ -281,15 +287,31 @@ class _VideoChatState extends State { 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 { children: [ if (!settingsStore.showVideo) appBar - else + else ...[ AspectRatio(aspectRatio: 16 / 9, child: video), + const Divider(), + ], Expanded(child: chat), ], ), diff --git a/lib/screens/channel/chat/details/chat_details.dart b/lib/screens/channel/chat/details/chat_details.dart index 1189ca0..6c0d2a4 100644 --- a/lib/screens/channel/chat/details/chat_details.dart +++ b/lib/screens/channel/chat/details/chat_details.dart @@ -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, ), ), ), diff --git a/lib/screens/channel/chat/details/chat_modes.dart b/lib/screens/channel/chat/details/chat_modes.dart index 0caf39c..63384b8 100644 --- a/lib/screens/channel/chat/details/chat_modes.dart +++ b/lib/screens/channel/chat/details/chat_modes.dart @@ -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, ), ), ], diff --git a/lib/screens/channel/chat/details/chat_users_list.dart b/lib/screens/channel/chat/details/chat_users_list.dart index 5e063c4..58336db 100644 --- a/lib/screens/channel/chat/details/chat_users_list.dart +++ b/lib/screens/channel/chat/details/chat_users_list.dart @@ -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 { @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().getUser( - headers: context - .read() - .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() + .getUser( + headers: context + .read() + .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, + ), + ), + ], + ), + ), + ), + ], + ), ); } diff --git a/lib/screens/channel/chat/stores/chat_store.dart b/lib/screens/channel/chat/stores/chat_store.dart index 42a6cb9..6b45ae0 100644 --- a/lib/screens/channel/chat/stores/chat_store.dart +++ b/lib/screens/channel/chat/stores/chat_store.dart @@ -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]. diff --git a/lib/screens/channel/chat/widgets/chat_bottom_bar.dart b/lib/screens/channel/chat/widgets/chat_bottom_bar.dart index 3ffbc32..9455975 100644 --- a/lib/screens/channel/chat/widgets/chat_bottom_bar.dart +++ b/lib/screens/channel/chat/widgets/chat_bottom_bar.dart @@ -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( diff --git a/lib/screens/channel/chat/widgets/chat_message.dart b/lib/screens/channel/chat/widgets/chat_message.dart index 5e77b5d..37807a5 100644 --- a/lib/screens/channel/chat/widgets/chat_message.dart +++ b/lib/screens/channel/chat/widgets/chat_message.dart @@ -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; diff --git a/lib/screens/channel/chat/widgets/chat_user_modal.dart b/lib/screens/channel/chat/widgets/chat_user_modal.dart index 7539600..9824ced 100644 --- a/lib/screens/channel/chat/widgets/chat_user_modal.dart +++ b/lib/screens/channel/chat/widgets/chat_user_modal.dart @@ -84,6 +84,7 @@ class _ChatUserModalState extends State { ], ), ), + const Divider(), Expanded( child: Observer( builder: (context) { diff --git a/lib/screens/channel/chat/widgets/reply_thread.dart b/lib/screens/channel/chat/widgets/reply_thread.dart index 0d6b7a9..85d1508 100644 --- a/lib/screens/channel/chat/widgets/reply_thread.dart +++ b/lib/screens/channel/chat/widgets/reply_thread.dart @@ -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(), - ), - ), - ], + ], + ), ), - ), - ); - }, + ); + }, + ), ); } } diff --git a/lib/screens/channel/video/video_overlay.dart b/lib/screens/channel/video/video_overlay.dart index 265fef2..c0a0e0f 100644 --- a/lib/screens/channel/video/video_overlay.dart +++ b/lib/screens/channel/video/video_overlay.dart @@ -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().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, ), diff --git a/lib/screens/home/home.dart b/lib/screens/home/home.dart index d750ff8..2e797cd 100644 --- a/lib/screens/home/home.dart +++ b/lib/screens/home/home.dart @@ -61,6 +61,7 @@ class _HomeState extends State { child: Scaffold( appBar: AppBar( centerTitle: false, + shape: const Border(), title: Observer( builder: (_) { final titles = [ @@ -73,19 +74,22 @@ class _HomeState extends State { }, ), 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()), + 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()), + ), ), ), ), @@ -111,31 +115,44 @@ class _HomeState extends State { ), ), ), - 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, - ), + ), + ], ), ), ); diff --git a/lib/screens/home/search/search.dart b/lib/screens/home/search/search.dart index 9beb3d3..926e367 100644 --- a/lib/screens/home/search/search.dart +++ b/lib/screens/home/search/search.dart @@ -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 { 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 { ); }, ), + AnimatedScrollBorder(scrollController: widget.scrollController), Expanded( child: Scrollbar( controller: widget.scrollController, @@ -87,6 +89,7 @@ class _SearchState extends State { const SectionHeader( 'History', padding: EdgeInsets.zero, + isFirst: true, ), TextButton( onPressed: _searchStore.searchHistory.clear, @@ -129,7 +132,7 @@ class _SearchState extends State { const SliverToBoxAdapter( child: SectionHeader( 'Channels', - padding: EdgeInsets.fromLTRB(16, 12, 16, 8), + isFirst: true, ), ), SearchResultsChannels( @@ -139,7 +142,6 @@ class _SearchState extends State { const SliverToBoxAdapter( child: SectionHeader( 'Categories', - padding: EdgeInsets.fromLTRB(16, 16, 16, 8), ), ), SearchResultsCategories(searchStore: _searchStore), diff --git a/lib/screens/home/stream_list/large_stream_card.dart b/lib/screens/home/stream_list/large_stream_card.dart index 202a360..fa37af3 100644 --- a/lib/screens/home/stream_list/large_stream_card.dart +++ b/lib/screens/home/stream_list/large_stream_card.dart @@ -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().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, ), ), diff --git a/lib/screens/home/stream_list/stream_card.dart b/lib/screens/home/stream_list/stream_card.dart index 4cffdba..cc7ebb0 100644 --- a/lib/screens/home/stream_list/stream_card.dart +++ b/lib/screens/home/stream_list/stream_card.dart @@ -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().dark.colorScheme.onSurface, ), ), ), diff --git a/lib/screens/home/stream_list/streams_list.dart b/lib/screens/home/stream_list/streams_list.dart index 945202f..0bda716 100644 --- a/lib/screens/home/stream_list/streams_list.dart +++ b/lib/screens/home/stream_list/streams_list.dart @@ -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 category: _listStore.categoryDetails!, isTappable: false, ), + if (_listStore.scrollController != null) + AnimatedScrollBorder( + scrollController: _listStore.scrollController!, + ), Expanded( child: Scrollbar( controller: _listStore.scrollController, diff --git a/lib/screens/home/top/categories/categories.dart b/lib/screens/home/top/categories/categories.dart index a546c7c..863906f 100644 --- a/lib/screens/home/top/categories/categories.dart +++ b/lib/screens/home/top/categories/categories.dart @@ -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 ); } - 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], + ); + }, + ), + ), + ), + ], ); }, ), diff --git a/lib/screens/home/top/categories/category_card.dart b/lib/screens/home/top/categories/category_card.dart index 7cb9b21..0aff43b 100644 --- a/lib/screens/home/top/categories/category_card.dart +++ b/lib/screens/home/top/categories/category_card.dart @@ -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(), ), ), diff --git a/lib/screens/home/top/categories/category_streams.dart b/lib/screens/home/top/categories/category_streams.dart index 184b2c5..7a016a7 100644 --- a/lib/screens/home/top/categories/category_streams.dart +++ b/lib/screens/home/top/categories/category_streams.dart @@ -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, diff --git a/lib/screens/settings/account/widgets/profile_card.dart b/lib/screens/settings/account/widgets/profile_card.dart index ca9b55d..7ed2bfe 100644 --- a/lib/screens/settings/account/widgets/profile_card.dart +++ b/lib/screens/settings/account/widgets/profile_card.dart @@ -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( diff --git a/lib/screens/settings/chat_settings.dart b/lib/screens/settings/chat_settings.dart index 5ac79cb..66b36e3 100644 --- a/lib/screens/settings/chat_settings.dart +++ b/lib/screens/settings/chat_settings.dart @@ -30,12 +30,15 @@ class _ChatSettingsState extends State { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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( diff --git a/lib/screens/settings/general_settings.dart b/lib/screens/settings/general_settings.dart index da9c57b..8e17471 100644 --- a/lib/screens/settings/general_settings.dart +++ b/lib/screens/settings/general_settings.dart @@ -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, diff --git a/lib/screens/settings/settings.dart b/lib/screens/settings/settings.dart index 625490e..38f121c 100644 --- a/lib/screens/settings/settings.dart +++ b/lib/screens/settings/settings.dart @@ -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()), - 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), ], ), diff --git a/lib/screens/settings/stores/settings_store.dart b/lib/screens/settings/stores/settings_store.dart index 30dd849..8f5f6a6 100644 --- a/lib/screens/settings/stores/settings_store.dart +++ b/lib/screens/settings/stores/settings_store.dart @@ -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']; diff --git a/lib/screens/settings/stores/settings_store.g.dart b/lib/screens/settings/stores/settings_store.g.dart index 8588421..4b8b23c 100644 --- a/lib/screens/settings/stores/settings_store.g.dart +++ b/lib/screens/settings/stores/settings_store.g.dart @@ -11,6 +11,7 @@ SettingsStore _$SettingsStoreFromJson(Map 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 json) => Map _$SettingsStoreToJson(SettingsStore instance) => { '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}, diff --git a/lib/screens/settings/video_settings.dart b/lib/screens/settings/video_settings.dart index cb9ceed..afa5d8e 100644 --- a/lib/screens/settings/video_settings.dart +++ b/lib/screens/settings/video_settings.dart @@ -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( diff --git a/lib/screens/settings/widgets/settings_list_select.dart b/lib/screens/settings/widgets/settings_list_select.dart index c4db55d..807d4da 100644 --- a/lib/screens/settings/widgets/settings_list_select.dart +++ b/lib/screens/settings/widgets/settings_list_select.dart @@ -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 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 diff --git a/lib/screens/settings/widgets/settings_list_slider.dart b/lib/screens/settings/widgets/settings_list_slider.dart index c11b922..e720ef8 100644 --- a/lib/screens/settings/widgets/settings_list_slider.dart +++ b/lib/screens/settings/widgets/settings_list_slider.dart @@ -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!), + ], + ), ), ); } diff --git a/lib/screens/settings/widgets/settings_list_switch.dart b/lib/screens/settings/widgets/settings_list_switch.dart index 64e80ef..bcad439 100644 --- a/lib/screens/settings/widgets/settings_list_switch.dart +++ b/lib/screens/settings/widgets/settings_list_switch.dart @@ -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, diff --git a/lib/theme.dart b/lib/theme.dart index 7208196..1f4f675 100644 --- a/lib/theme.dart +++ b/lib/theme.dart @@ -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, ); diff --git a/lib/widgets/alert_message.dart b/lib/widgets/alert_message.dart index 801d064..fb3226c 100644 --- a/lib/widgets/alert_message.dart +++ b/lib/widgets/alert_message.dart @@ -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, ), ), ), diff --git a/lib/widgets/animated_scroll_border.dart b/lib/widgets/animated_scroll_border.dart new file mode 100644 index 0000000..5089aac --- /dev/null +++ b/lib/widgets/animated_scroll_border.dart @@ -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 createState() => _AnimatedScrollBorderState(); +} + +class _AnimatedScrollBorderState extends State { + 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, + ), + ); + } +} diff --git a/lib/widgets/dialog.dart b/lib/widgets/dialog.dart index 2d0748f..075c0d6 100644 --- a/lib/widgets/dialog.dart +++ b/lib/widgets/dialog.dart @@ -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), diff --git a/lib/widgets/loading_indicator.dart b/lib/widgets/loading_indicator.dart index 1732bed..88d93c2 100644 --- a/lib/widgets/loading_indicator.dart +++ b/lib/widgets/loading_indicator.dart @@ -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), ), ), ], diff --git a/lib/widgets/notification.dart b/lib/widgets/notification.dart index eb70465..584e65e 100644 --- a/lib/widgets/notification.dart +++ b/lib/widgets/notification.dart @@ -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, ), ), diff --git a/lib/widgets/photo_view.dart b/lib/widgets/photo_view.dart index dfa44aa..b3b1cfd 100644 --- a/lib/widgets/photo_view.dart +++ b/lib/widgets/photo_view.dart @@ -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 { ), ), IconButton( - icon: const Icon(Icons.close, color: Colors.white), + icon: Icon( + Icons.close, + color: context.watch().dark.colorScheme.onSurface, + ), onPressed: Navigator.of(context).pop, ), ], diff --git a/lib/widgets/profile_picture.dart b/lib/widgets/profile_picture.dart index 0d88e63..4233dca 100644 --- a/lib/widgets/profile_picture.dart +++ b/lib/widgets/profile_picture.dart @@ -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().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, ); diff --git a/lib/widgets/scroll_to_top_button.dart b/lib/widgets/scroll_to_top_button.dart index af90718..1eacf30 100644 --- a/lib/widgets/scroll_to_top_button.dart +++ b/lib/widgets/scroll_to_top_button.dart @@ -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), ), diff --git a/lib/widgets/section_header.dart b/lib/widgets/section_header.dart index e46d658..baf41a1 100644 --- a/lib/widgets/section_header.dart +++ b/lib/widgets/section_header.dart @@ -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, ), ), - ), - ], + ], + ), ); } } diff --git a/pubspec.lock b/pubspec.lock index 275e803..cb37256 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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: diff --git a/pubspec.yaml b/pubspec.yaml index 4c1560d..a908606 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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: