diff --git a/lib/main.dart b/lib/main.dart index a725658..0f00267 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; +import 'router.dart'; + void main() { runApp(const MainApp()); } @@ -9,12 +11,11 @@ class MainApp extends StatelessWidget { @override Widget build(BuildContext context) { - return const MaterialApp( - home: Scaffold( - body: Center( - child: Text('Hello World!'), - ), - ), + return MaterialApp.router( + themeMode: ThemeMode.dark, + darkTheme: ThemeData.dark(useMaterial3: true), + debugShowCheckedModeBanner: false, + routerConfig: router, ); } } diff --git a/lib/router.dart b/lib/router.dart new file mode 100644 index 0000000..d7f809a --- /dev/null +++ b/lib/router.dart @@ -0,0 +1,11 @@ +import 'package:go_router/go_router.dart'; +import 'package:subtracks/screens/home.dart'; + +final router = GoRouter( + routes: [ + GoRoute( + path: '/', + builder: (context, state) => HomeScreen(), + ), + ], +); diff --git a/lib/screens/home.dart b/lib/screens/home.dart new file mode 100644 index 0000000..3b3b296 --- /dev/null +++ b/lib/screens/home.dart @@ -0,0 +1,218 @@ +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../util/custom_scroll_fix.dart'; + +class HomeScreen extends StatefulWidget { + const HomeScreen({super.key}); + + @override + State createState() => _HomeScreenState(); +} + +class _HomeScreenState extends State + with SingleTickerProviderStateMixin { + late final TabController tabController; + + final iconSize = 26.0; + final tabHeight = 32.0; + + late final List<(String, Widget)> tabs = [ + ("Home", Icon(Symbols.home_rounded, size: iconSize)), + ("Albums", Icon(Symbols.album_rounded, size: iconSize)), + ("Artists", Icon(Symbols.person_rounded, size: iconSize)), + ("Songs", Icon(Symbols.music_note_rounded, size: iconSize)), + ("Playlists", Icon(Symbols.playlist_play_rounded, size: iconSize)), + ]; + + @override + void initState() { + super.initState(); + tabController = TabController( + length: tabs.length, + initialIndex: 1, + vsync: this, + ); + } + + @override + void dispose() { + tabController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return IconTheme( + data: IconThemeData( + fill: 1, + color: TextTheme.of(context).headlineLarge?.color, + weight: 600, + opticalSize: iconSize, + ), + child: Scaffold( + body: NestedScrollView( + floatHeaderSlivers: true, + headerSliverBuilder: (context, innerBoxIsScrolled) { + return [ + SliverOverlapAbsorber( + handle: NestedScrollView.sliverOverlapAbsorberHandleFor( + context, + ), + sliver: SliverAppBar( + flexibleSpace: FlexibleSpaceBar( + collapseMode: CollapseMode.pin, + background: SafeArea( + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: 18, + vertical: 16, + ), + child: Text( + "Albums", + style: TextTheme.of(context).headlineLarge?.copyWith( + fontWeight: FontWeight.w800, + ), + ), + ), + ), + ), + pinned: true, + floating: true, + bottom: PreferredSize( + preferredSize: Size.fromHeight(tabHeight + 18), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + TabBar( + controller: tabController, + dividerColor: Colors.transparent, + isScrollable: true, + tabAlignment: TabAlignment.start, + indicatorSize: TabBarIndicatorSize.label, + labelPadding: EdgeInsets.symmetric( + horizontal: 2, + ), + labelColor: Theme.of(context).primaryColorDark, + unselectedLabelColor: Theme.of( + context, + ).textTheme.headlineLarge?.color, + padding: EdgeInsets.symmetric( + // horizontal: 12, + vertical: 8, + ), + splashBorderRadius: BorderRadius.circular(8), + indicator: BoxDecoration( + color: Theme.of( + context, + ).primaryTextTheme.headlineLarge?.color, + borderRadius: BorderRadius.circular(8), + ), + tabs: tabs + .map( + (tab) => Tab( + height: tabHeight, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 6, + ), + child: tab.$2, + ), + ), + ) + .toList(), + ), + IconButton( + onPressed: () {}, + icon: Icon( + Symbols.settings_rounded, + ), + ), + ], + ), + ), + ), + ), + ), + ]; + }, + body: Builder( + builder: (context) { + return CustomScrollProvider( + tabController: tabController, + parent: PrimaryScrollController.of(context), + child: TabBarView( + // These are the contents of the tab views, below the tabs. + controller: tabController, + children: tabs.map((tab) { + final index = tabs.indexOf(tab); + return SafeArea( + top: false, + bottom: false, + child: NewWidget(index: index, tab: tab), + ); + }).toList(), + ), + ); + }, + ), + ), + ), + ); + } +} + +class NewWidget extends StatefulWidget { + const NewWidget({ + super.key, + required this.index, + required this.tab, + }); + + final int index; + final (String, Widget) tab; + + @override + State createState() => _NewWidgetState(); +} + +class _NewWidgetState extends State + with AutomaticKeepAliveClientMixin { + @override + bool get wantKeepAlive => true; + + @override + Widget build(BuildContext context) { + super.build(context); + + final scrollProvider = CustomScrollProviderData.of(context); + + return CustomScrollView( + controller: scrollProvider.scrollControllers[widget.index], + slivers: [ + SliverOverlapInjector( + handle: NestedScrollView.sliverOverlapAbsorberHandleFor( + context, + ), + ), + SliverPadding( + padding: const EdgeInsets.all(8.0), + sliver: SliverFixedExtentList( + itemExtent: 48.0, + delegate: SliverChildBuilderDelegate( + (BuildContext context, int index) { + return ListTile( + title: Text('Item $index'), + onTap: () {}, + ); + }, + childCount: 30, + ), + ), + ), + ], + ); + } +} diff --git a/lib/util/custom_scroll_fix.dart b/lib/util/custom_scroll_fix.dart new file mode 100644 index 0000000..f0e28c4 --- /dev/null +++ b/lib/util/custom_scroll_fix.dart @@ -0,0 +1,175 @@ +import 'package:flutter/material.dart'; + +// Fixes scroll postion being reset in a tab when scrolling up inside a +// NestedScrollView. +// +// https://dartpad.dev/?id=5d1a0c7c4b55281ccff377fc8ea90b60 +// https://github.com/flutter/flutter/issues/159123#issuecomment-2614343654 + +class CustomScrollProvider extends StatefulWidget { + const CustomScrollProvider({ + super.key, + required this.tabController, + required this.parent, + required this.child, + }); + + final TabController tabController; + final ScrollController parent; + final Widget child; + + @override + State createState() => _CustomScrollProviderState(); +} + +class _CustomScrollProviderState extends State { + late final List scrollControllers; + + @override + void initState() { + super.initState(); + + final activeIndex = widget.tabController.index; + + scrollControllers = List.generate( + widget.tabController.length, + (index) => CustomScrollController( + isActive: index == activeIndex, + parent: widget.parent, + debugLabel: 'CustomScrollController/$index', + ), + ); + + widget.tabController.addListener(() { + changeActiveIndex(widget.tabController.index); + }); + } + + @override + void dispose() { + for (final scrollController in scrollControllers) { + scrollController.dispose(); + } + + super.dispose(); + } + + void changeActiveIndex(int value) { + for (var i = 0; i < scrollControllers.length; i++) { + final scrollController = scrollControllers[i]; + final isActive = i == value; + scrollController.isActive = isActive; + + if (isActive) { + scrollController.forceAttach(); + } else { + scrollController.forceDetach(); + } + } + } + + @override + Widget build(BuildContext context) { + return CustomScrollProviderData( + scrollControllers: scrollControllers, + child: widget.child, + ); + } +} + +class CustomScrollProviderData extends InheritedWidget { + const CustomScrollProviderData({ + super.key, + required super.child, + required this.scrollControllers, + }); + + static CustomScrollProviderData of(BuildContext context) { + return context + .dependOnInheritedWidgetOfExactType()!; + } + + final List scrollControllers; + + @override + bool updateShouldNotify(CustomScrollProviderData oldWidget) { + return scrollControllers != oldWidget.scrollControllers; + } +} + +class CustomScrollController extends ScrollController { + CustomScrollController({ + required this.isActive, + required this.parent, + String debugLabel = 'CustomScrollController', + }) : super( + debugLabel: parent.debugLabel == null + ? null + : '${parent.debugLabel}/$debugLabel', + initialScrollOffset: parent.initialScrollOffset, + keepScrollOffset: parent.keepScrollOffset, + ); + + bool isActive; + final ScrollController parent; + + @override + ScrollPosition createScrollPosition( + ScrollPhysics physics, + ScrollContext context, + ScrollPosition? oldPosition, + ) { + debugPrint('$debugLabel-createScrollPosition: $isActive'); + + return parent.createScrollPosition(physics, context, oldPosition); + } + + @override + void attach(ScrollPosition position) { + debugPrint('$debugLabel-attach: $isActive'); + + super.attach(position); + if (isActive && !parent.positions.contains(position)) { + parent.attach(position); + } + } + + @override + void detach(ScrollPosition position) { + debugPrint('$debugLabel-detach: $isActive'); + + if (parent.positions.contains(position)) { + parent.detach(position); + } + + super.detach(position); + } + + void forceDetach() { + debugPrint('$debugLabel-forceDetach: $isActive'); + + for (final position in positions) { + if (parent.positions.contains(position)) { + parent.detach(position); + } + } + } + + void forceAttach() { + debugPrint('$debugLabel-forceAttach: $isActive'); + + for (final position in positions) { + if (!parent.positions.contains(position)) { + parent.attach(position); + } + } + } + + @override + void dispose() { + debugPrint('$debugLabel-dispose: $isActive'); + + forceDetach(); + super.dispose(); + } +} diff --git a/pubspec.lock b/pubspec.lock index 4170894..be386a0 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,6 +1,14 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" async: dependency: transitive description: @@ -17,6 +25,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" + chalkdart: + dependency: transitive + description: + name: chalkdart + sha256: "7ffc6bd39c81453fb9ba8dbce042a9c960219b75ea1c07196a7fa41c2fab9e86" + url: "https://pub.dev" + source: hosted + version: "3.0.5" characters: dependency: transitive description: @@ -49,6 +65,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.3" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" flutter: dependency: "direct main" description: flutter @@ -67,6 +91,27 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + go_router: + dependency: "direct main" + description: + name: go_router + sha256: e1d7ffb0db475e6e845eb58b44768f50b830e23960e3df6908924acd8f7f70ea + url: "https://pub.dev" + source: hosted + version: "16.2.5" leak_tracker: dependency: transitive description: @@ -99,6 +144,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.1.1" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" matcher: dependency: transitive description: @@ -115,6 +168,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.11.1" + material_symbols_icons: + dependency: "direct main" + description: + name: material_symbols_icons + sha256: "9a7de58ffc299c8e362b4e860e36e1d198fa0981a894376fe1b6bfe52773e15b" + url: "https://pub.dev" + source: hosted + version: "4.2874.0" meta: dependency: transitive description: @@ -202,4 +263,4 @@ packages: version: "15.0.2" sdks: dart: ">=3.9.2 <4.0.0" - flutter: ">=3.18.0-18.0.pre.54" + flutter: ">=3.29.0" diff --git a/pubspec.yaml b/pubspec.yaml index ac69a98..50c765f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -9,6 +9,8 @@ environment: dependencies: flutter: sdk: flutter + go_router: ^16.2.5 + material_symbols_icons: ^4.2874.0 dev_dependencies: flutter_test: