diff --git a/lib/screens/home/home.dart b/lib/screens/home/home.dart index d200f999..24eec411 100644 --- a/lib/screens/home/home.dart +++ b/lib/screens/home/home.dart @@ -5,11 +5,12 @@ import 'package:frosty/screens/home/home_store.dart'; import 'package:frosty/screens/home/search/search.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/screens/home/top/top.dart'; +import 'package:frosty/screens/home/top/categories/categories.dart'; import 'package:frosty/screens/settings/settings.dart'; import 'package:frosty/screens/settings/stores/auth_store.dart'; import 'package:frosty/screens/settings/stores/settings_store.dart'; import 'package:frosty/screens/settings/widgets/release_notes.dart'; +import 'package:frosty/widgets/app_bar.dart'; import 'package:frosty/widgets/profile_picture.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:provider/provider.dart'; @@ -63,13 +64,15 @@ class _HomeState extends State { return GestureDetector( onTap: FocusScope.of(context).unfocus, child: Scaffold( - appBar: AppBar( - shape: const Border(), + extendBodyBehindAppBar: true, + appBar: FrostyAppBar( + showBackButton: false, title: Observer( builder: (_) { final titles = [ if (_authStore.isLoggedIn) 'Following', - 'Top', + 'Top Streams', + 'Top Categories', 'Search', ]; @@ -99,6 +102,7 @@ class _HomeState extends State { ], ), body: SafeArea( + top: false, child: Observer( builder: (_) => IndexedStack( index: _homeStore.selectedIndex, @@ -107,9 +111,15 @@ class _HomeState extends State { StreamsList( listType: ListType.followed, scrollController: _homeStore.followedScrollController, + addTopPadding: true, ), - TopSection( - homeStore: _homeStore, + StreamsList( + listType: ListType.top, + scrollController: _homeStore.topSectionScrollControllers[0], + addTopPadding: true, + ), + Categories( + scrollController: _homeStore.topSectionScrollControllers[1], ), Search( scrollController: _homeStore.searchScrollController, @@ -139,8 +149,16 @@ class _HomeState extends State { Icons.arrow_upward_rounded, ), selectedIcon: Icon(Icons.arrow_upward_rounded), - label: 'Top', - tooltip: 'Top', + label: 'Top streams', + tooltip: 'Top streams', + ), + const NavigationDestination( + icon: Icon( + Icons.gamepad_outlined, + ), + selectedIcon: Icon(Icons.gamepad_rounded), + label: 'Top categories', + tooltip: 'Top categories', ), const NavigationDestination( icon: Icon( diff --git a/lib/screens/home/search/search.dart b/lib/screens/home/search/search.dart index 13c579ce..e2d3b6be 100644 --- a/lib/screens/home/search/search.dart +++ b/lib/screens/home/search/search.dart @@ -33,126 +33,133 @@ class _SearchState extends State { @override Widget build(BuildContext context) { - return Column( - children: [ - Observer( - builder: (context) { - return Padding( - padding: const EdgeInsets.all(16), - child: TextField( - controller: _searchStore.textEditingController, - focusNode: _searchStore.textFieldFocusNode, - autocorrect: false, - decoration: InputDecoration( - prefixIcon: const Icon(Icons.search_rounded), - hintText: 'Find a channel or category', - suffixIcon: _searchStore.textFieldFocusNode.hasFocus || - _searchStore.searchText.isNotEmpty - ? IconButton( - icon: const Icon(Icons.close_rounded), - tooltip: _searchStore.searchText.isEmpty - ? 'Cancel' - : 'Clear', - onPressed: () { - if (_searchStore.searchText.isEmpty) { - _searchStore.textFieldFocusNode.unfocus(); - } - _searchStore.textEditingController.clear(); - }, - ) - : null, + return SafeArea( + child: Column( + children: [ + Observer( + builder: (context) { + return Padding( + padding: const EdgeInsets.all(16), + child: TextField( + controller: _searchStore.textEditingController, + focusNode: _searchStore.textFieldFocusNode, + autocorrect: false, + decoration: InputDecoration( + prefixIcon: const Icon(Icons.search_rounded), + hintText: 'Find a channel or category', + suffixIcon: _searchStore.textFieldFocusNode.hasFocus || + _searchStore.searchText.isNotEmpty + ? IconButton( + icon: const Icon(Icons.close_rounded), + tooltip: _searchStore.searchText.isEmpty + ? 'Cancel' + : 'Clear', + onPressed: () { + if (_searchStore.searchText.isEmpty) { + _searchStore.textFieldFocusNode.unfocus(); + } + _searchStore.textEditingController.clear(); + }, + ) + : null, + ), + onSubmitted: _searchStore.handleQuery, ), - onSubmitted: _searchStore.handleQuery, - ), - ); - }, - ), - AnimatedScrollBorder(scrollController: widget.scrollController), - Expanded( - child: Scrollbar( - controller: widget.scrollController, - child: Observer( - builder: (context) { - if (_searchStore.textEditingController.text.isEmpty) { - if (_searchStore.searchHistory.isEmpty) { - return const AlertMessage(message: 'No recent searches'); - } + ); + }, + ), + AnimatedScrollBorder(scrollController: widget.scrollController), + Expanded( + child: Scrollbar( + controller: widget.scrollController, + child: Observer( + builder: (context) { + if (_searchStore.textEditingController.text.isEmpty) { + if (_searchStore.searchHistory.isEmpty) { + return const AlertMessage( + message: 'No recent searches', + ); + } - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.only(left: 16, right: 4), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const SectionHeader( - 'History', - padding: EdgeInsets.zero, - isFirst: true, - ), - TextButton( - onPressed: _searchStore.searchHistory.clear, - child: const Text('Clear'), - ), - ], + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(left: 16, right: 4), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const SectionHeader( + 'History', + padding: EdgeInsets.zero, + isFirst: true, + ), + TextButton( + onPressed: _searchStore.searchHistory.clear, + child: const Text('Clear'), + ), + ], + ), + ), + Expanded( + child: ListView( + controller: widget.scrollController, + children: _searchStore.searchHistory + .mapIndexed( + (index, searchTerm) => ListTile( + leading: const Icon(Icons.history_rounded), + title: Text(searchTerm), + onTap: () { + _searchStore.textEditingController.text = + searchTerm; + _searchStore.handleQuery(searchTerm); + _searchStore + .textEditingController.selection = + TextSelection.fromPosition( + TextPosition( + offset: _searchStore + .textEditingController + .text + .length, + ), + ); + }, + ), + ) + .toList(), + ), + ), + ], + ); + } + return CustomScrollView( + controller: widget.scrollController, + slivers: [ + const SliverToBoxAdapter( + child: SectionHeader( + 'Channels', + isFirst: true, + padding: EdgeInsets.fromLTRB(16, 12, 16, 8), ), ), - Expanded( - child: ListView( - controller: widget.scrollController, - children: _searchStore.searchHistory - .mapIndexed( - (index, searchTerm) => ListTile( - leading: const Icon(Icons.history_rounded), - title: Text(searchTerm), - onTap: () { - _searchStore.textEditingController.text = - searchTerm; - _searchStore.handleQuery(searchTerm); - _searchStore.textEditingController - .selection = TextSelection.fromPosition( - TextPosition( - offset: _searchStore - .textEditingController.text.length, - ), - ); - }, - ), - ) - .toList(), + SearchResultsChannels( + searchStore: _searchStore, + query: _searchStore.searchText, + ), + const SliverToBoxAdapter( + child: SectionHeader( + 'Categories', ), ), + SearchResultsCategories(searchStore: _searchStore), ], ); - } - return CustomScrollView( - controller: widget.scrollController, - slivers: [ - const SliverToBoxAdapter( - child: SectionHeader( - 'Channels', - isFirst: true, - padding: EdgeInsets.fromLTRB(16, 12, 16, 8), - ), - ), - SearchResultsChannels( - searchStore: _searchStore, - query: _searchStore.searchText, - ), - const SliverToBoxAdapter( - child: SectionHeader( - 'Categories', - ), - ), - SearchResultsCategories(searchStore: _searchStore), - ], - ); - }, + }, + ), ), ), - ), - ], + ], + ), ); } diff --git a/lib/screens/home/stream_list/streams_list.dart b/lib/screens/home/stream_list/streams_list.dart index 4028b162..454fe94c 100644 --- a/lib/screens/home/stream_list/streams_list.dart +++ b/lib/screens/home/stream_list/streams_list.dart @@ -9,7 +9,6 @@ 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:frosty/widgets/section_header.dart'; @@ -27,6 +26,7 @@ class StreamsList extends StatefulWidget { final ScrollController? scrollController; final bool showJumpButton; + final bool addTopPadding; const StreamsList({ super.key, @@ -34,6 +34,7 @@ class StreamsList extends StatefulWidget { this.categoryId, this.scrollController, this.showJumpButton = false, + this.addTopPadding = false, }); @override @@ -74,9 +75,13 @@ class _StreamsListState extends State Widget build(BuildContext context) { super.build(context); + final double topPadding = + widget.addTopPadding ? MediaQuery.of(context).padding.top : 0; + _listStore.checkLastTimeRefreshedAndUpdate(); return RefreshIndicator.adaptive( + edgeOffset: topPadding, onRefresh: () async { HapticFeedback.lightImpact(); @@ -152,10 +157,6 @@ class _StreamsListState extends State category: _listStore.categoryDetails!, isTappable: false, ), - if (_listStore.scrollController != null) - AnimatedScrollBorder( - scrollController: _listStore.scrollController!, - ), Expanded( child: Scrollbar( controller: _listStore.scrollController, @@ -163,6 +164,12 @@ class _StreamsListState extends State physics: const AlwaysScrollableScrollPhysics(), controller: _listStore.scrollController, slivers: [ + if (widget.addTopPadding) + SliverPadding( + padding: EdgeInsets.only( + top: topPadding, + ), + ), if (isFollowingTab && _listStore.pinnedStreams.isNotEmpty) ...[ const SliverToBoxAdapter( diff --git a/lib/screens/home/top/categories/categories.dart b/lib/screens/home/top/categories/categories.dart index 863906ff..19e483a5 100644 --- a/lib/screens/home/top/categories/categories.dart +++ b/lib/screens/home/top/categories/categories.dart @@ -54,6 +54,7 @@ class _CategoriesState extends State _categoriesStore.checkLastTimeRefreshedAndUpdate(); return RefreshIndicator.adaptive( + edgeOffset: MediaQuery.of(context).padding.top, onRefresh: () async { HapticFeedback.lightImpact(); await _categoriesStore.refreshCategories(); diff --git a/lib/screens/home/top/top.dart b/lib/screens/home/top/top.dart deleted file mode 100644 index 4cae1112..00000000 --- a/lib/screens/home/top/top.dart +++ /dev/null @@ -1,71 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:frosty/screens/home/home_store.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/screens/home/top/categories/categories.dart'; - -class TopSection extends StatefulWidget { - final HomeStore homeStore; - - const TopSection({ - super.key, - required this.homeStore, - }); - - @override - State createState() => _TopSectionState(); -} - -class _TopSectionState extends State - with SingleTickerProviderStateMixin { - late final _tabBarController = TabController(length: 2, vsync: this); - - @override - void initState() { - super.initState(); - _tabBarController.addListener( - () => widget.homeStore.topSectionCurrentIndex = _tabBarController.index, - ); - } - - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - TabBar( - isScrollable: true, - controller: _tabBarController, - tabs: const [ - Tab( - child: Text('Streams'), - ), - Tab(child: Text('Categories')), - ], - ), - Expanded( - child: TabBarView( - controller: _tabBarController, - children: [ - StreamsList( - listType: ListType.top, - scrollController: - widget.homeStore.topSectionScrollControllers[0], - ), - Categories( - scrollController: - widget.homeStore.topSectionScrollControllers[1], - ), - ], - ), - ), - ], - ); - } - - @override - void dispose() { - _tabBarController.dispose(); - super.dispose(); - } -} diff --git a/lib/screens/settings/chat_settings.dart b/lib/screens/settings/chat_settings.dart index 6c4129bf..59a2eebc 100644 --- a/lib/screens/settings/chat_settings.dart +++ b/lib/screens/settings/chat_settings.dart @@ -31,7 +31,6 @@ class _ChatSettingsState extends State { return Observer( builder: (context) => ListView( - padding: const EdgeInsets.only(top: 16), children: [ const SectionHeader( 'Message sizing', diff --git a/lib/screens/settings/general_settings.dart b/lib/screens/settings/general_settings.dart index 4c5b347c..5dfb13aa 100644 --- a/lib/screens/settings/general_settings.dart +++ b/lib/screens/settings/general_settings.dart @@ -16,7 +16,6 @@ class GeneralSettings extends StatelessWidget { Widget build(BuildContext context) { return Observer( builder: (context) => ListView( - padding: const EdgeInsets.only(top: 16), children: [ const SectionHeader( 'Theme', diff --git a/lib/screens/settings/settings.dart b/lib/screens/settings/settings.dart index 38f121cd..91f3b203 100644 --- a/lib/screens/settings/settings.dart +++ b/lib/screens/settings/settings.dart @@ -23,6 +23,7 @@ class Settings extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( + extendBodyBehindAppBar: true, appBar: FrostyAppBar( title: const Text('Settings'), actions: [ @@ -53,12 +54,12 @@ class Settings extends StatelessWidget { ], ), body: SafeArea( + top: false, bottom: false, child: ListView( - padding: const EdgeInsets.only(top: 16), children: [ const SectionHeader( - 'Profile', + 'Account', isFirst: true, ), ProfileCard(authStore: context.read()), diff --git a/lib/screens/settings/video_settings.dart b/lib/screens/settings/video_settings.dart index c0e93fad..68cf2594 100644 --- a/lib/screens/settings/video_settings.dart +++ b/lib/screens/settings/video_settings.dart @@ -17,7 +17,6 @@ class VideoSettings extends StatelessWidget { Widget build(BuildContext context) { return Observer( builder: (context) => ListView( - padding: const EdgeInsets.only(top: 16), children: [ const SectionHeader( 'Player', @@ -34,7 +33,7 @@ class VideoSettings extends StatelessWidget { value: settingsStore.defaultToHighestQuality, onChanged: (newValue) => settingsStore.defaultToHighestQuality = newValue, - ), + ), if (Platform.isAndroid) SettingsListSwitch( title: 'Use enhanced rendering', diff --git a/lib/screens/settings/widgets/settings_tile_route.dart b/lib/screens/settings/widgets/settings_tile_route.dart index 52c1a94e..ef86c2d8 100644 --- a/lib/screens/settings/widgets/settings_tile_route.dart +++ b/lib/screens/settings/widgets/settings_tile_route.dart @@ -25,8 +25,10 @@ class SettingsTileRoute extends StatelessWidget { MaterialPageRoute( builder: (context) => useScaffold ? Scaffold( + extendBodyBehindAppBar: true, appBar: FrostyAppBar(title: Text(title)), body: SafeArea( + top: false, bottom: false, child: child, ), diff --git a/lib/theme.dart b/lib/theme.dart index 32ddc46e..459c8ab8 100644 --- a/lib/theme.dart +++ b/lib/theme.dart @@ -7,24 +7,29 @@ class FrostyThemes { const FrostyThemes({required this.colorSchemeSeed}); + ThemeData get light => createBaseTheme( + colorScheme: ColorScheme.fromSeed(seedColor: colorSchemeSeed), + backgroundColor: const Color.fromRGBO(248, 248, 248, 1), + ); + + ThemeData get dark => createBaseTheme( + colorScheme: ColorScheme.fromSeed( + seedColor: colorSchemeSeed, + brightness: Brightness.dark, + ), + backgroundColor: Colors.black, + ); + ThemeData createBaseTheme({ - required Brightness brightness, - required Color colorSchemeSeed, + required ColorScheme colorScheme, Color? backgroundColor, }) { - final colorScheme = ColorScheme.fromSeed( - seedColor: colorSchemeSeed, - brightness: brightness, - ); - final borderColor = colorScheme.outlineVariant; - const borderWidth = 0.5; return ThemeData( - fontFamily: 'Inter', - brightness: brightness, colorScheme: colorScheme, + fontFamily: 'Inter', splashFactory: Platform.isIOS ? NoSplash.splashFactory : null, scaffoldBackgroundColor: backgroundColor, bottomSheetTheme: BottomSheetThemeData( @@ -37,11 +42,14 @@ class FrostyThemes { surfaceTintColor: backgroundColor, ), appBarTheme: AppBarTheme( - elevation: 0, - backgroundColor: backgroundColor, + backgroundColor: Colors.transparent, surfaceTintColor: backgroundColor, - shape: Border( - bottom: BorderSide(color: borderColor, width: borderWidth), + ), + listTileTheme: ListTileThemeData( + titleTextStyle: TextStyle( + color: colorScheme.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, ), ), inputDecorationTheme: InputDecorationTheme( @@ -61,7 +69,6 @@ class FrostyThemes { ), ), navigationBarTheme: NavigationBarThemeData( - elevation: 0, backgroundColor: backgroundColor, height: 64, labelBehavior: NavigationDestinationLabelBehavior.alwaysHide, @@ -71,6 +78,7 @@ class FrostyThemes { tabAlignment: TabAlignment.start, ), tooltipTheme: TooltipThemeData( + padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: backgroundColor, borderRadius: const BorderRadius.all(Radius.circular(8)), @@ -133,24 +141,4 @@ class FrostyThemes { ), ); } - - ThemeData get light { - final theme = createBaseTheme( - brightness: Brightness.light, - colorSchemeSeed: colorSchemeSeed, - backgroundColor: const Color.fromRGBO(248, 248, 248, 1), - ); - - return theme; - } - - ThemeData get dark { - final theme = createBaseTheme( - brightness: Brightness.dark, - colorSchemeSeed: colorSchemeSeed, - backgroundColor: Colors.black, - ); - - return theme; - } } diff --git a/lib/widgets/app_bar.dart b/lib/widgets/app_bar.dart index 6bf4e436..e2880e64 100644 --- a/lib/widgets/app_bar.dart +++ b/lib/widgets/app_bar.dart @@ -1,26 +1,37 @@ +import 'dart:ui'; + import 'package:flutter/material.dart'; class FrostyAppBar extends StatelessWidget implements PreferredSizeWidget { - final Widget title; + final Widget? title; final List? actions; + final bool showBackButton; const FrostyAppBar({ super.key, - required this.title, + this.title, this.actions, + this.showBackButton = true, }); @override Widget build(BuildContext context) { - return AppBar( - leading: IconButton( - tooltip: 'Back', - icon: Icon(Icons.adaptive.arrow_back_rounded), - onPressed: Navigator.of(context).pop, + return ClipRRect( + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16), + child: AppBar( + leading: showBackButton + ? IconButton( + tooltip: 'Back', + icon: Icon(Icons.adaptive.arrow_back_rounded), + onPressed: Navigator.of(context).pop, + ) + : null, + title: title, + actions: actions, + centerTitle: false, + ), ), - title: title, - actions: actions, - centerTitle: false, ); } diff --git a/lib/widgets/section_header.dart b/lib/widgets/section_header.dart index baf41a1e..2d99c3d8 100644 --- a/lib/widgets/section_header.dart +++ b/lib/widgets/section_header.dart @@ -17,23 +17,17 @@ class SectionHeader extends StatelessWidget { @override Widget build(BuildContext context) { 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, - ), - ), - ], + padding: padding ?? + (isFirst + ? const EdgeInsets.fromLTRB(16, 16, 16, 8) + : const EdgeInsets.fromLTRB(16, 48, 16, 8)), + child: Text( + text.toUpperCase(), + style: TextStyle( + fontSize: fontSize ?? 14, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6), + ), ), ); }