diff --git a/.gitignore b/.gitignore index 04c23541d..da15dde4c 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ build/ *.bin .flutter-plugins* +packages/uni_ui/pubspec.lock # IDE files .DS_Store diff --git a/packages/uni_app/app_version.txt b/packages/uni_app/app_version.txt index db9c1f2ef..94ed247cc 100644 --- a/packages/uni_app/app_version.txt +++ b/packages/uni_app/app_version.txt @@ -1 +1 @@ -1.10.0-beta.25+328 +1.10.0-beta.33+336 diff --git a/packages/uni_app/lib/controller/fetchers/library_occupation_fetcher.dart b/packages/uni_app/lib/controller/fetchers/library_occupation_fetcher.dart index f0616bd13..be22fb683 100644 --- a/packages/uni_app/lib/controller/fetchers/library_occupation_fetcher.dart +++ b/packages/uni_app/lib/controller/fetchers/library_occupation_fetcher.dart @@ -39,6 +39,7 @@ class LibraryOccupationFetcher { }), ); + libraryOccupation.sortFloors(); return libraryOccupation; } diff --git a/packages/uni_app/lib/model/entities/library_occupation.dart b/packages/uni_app/lib/model/entities/library_occupation.dart index 9931652df..cdf692efd 100644 --- a/packages/uni_app/lib/model/entities/library_occupation.dart +++ b/packages/uni_app/lib/model/entities/library_occupation.dart @@ -37,6 +37,10 @@ class LibraryOccupation { return floors[number - 1]; } + void sortFloors() { + floors.sort((a, b) => a.number.compareTo(b.number)); + } + Map toJson() => _$LibraryOccupationToJson(this); } diff --git a/packages/uni_app/lib/model/providers/state_provider_notifier.dart b/packages/uni_app/lib/model/providers/state_provider_notifier.dart index 4043391d6..ab2cfade4 100644 --- a/packages/uni_app/lib/model/providers/state_provider_notifier.dart +++ b/packages/uni_app/lib/model/providers/state_provider_notifier.dart @@ -168,9 +168,9 @@ abstract class StateProviderNotifier extends ChangeNotifier { if (!context.mounted || _state != null) { return; } - await _loadFromStorage(context).then((value) { + await _loadFromStorage(context).then((value) async { if (context.mounted) { - _loadFromRemoteFromContext(context); + await _loadFromRemoteFromContext(context); } }); }, diff --git a/packages/uni_app/lib/view/home/widgets/exit_app_dialog.dart b/packages/uni_app/lib/view/home/widgets/exit_app_dialog.dart index 5454f6e3e..da241f604 100644 --- a/packages/uni_app/lib/view/home/widgets/exit_app_dialog.dart +++ b/packages/uni_app/lib/view/home/widgets/exit_app_dialog.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:uni/generated/l10n.dart'; /// Manages the app section displayed when the user presses the back button @@ -34,9 +35,9 @@ class BackButtonExitWrapper extends StatelessWidget { child: Text(S.of(context).no), ), ElevatedButton( - onPressed: () { + onPressed: () async { userActionCompleter.complete(true); - Navigator.of(context).pop(false); + await SystemNavigator.pop(); }, child: Text(S.of(context).yes), ), diff --git a/packages/uni_app/lib/view/home/widgets/schedule_card.dart b/packages/uni_app/lib/view/home/widgets/schedule_card.dart index 411051471..32edc2752 100644 --- a/packages/uni_app/lib/view/home/widgets/schedule_card.dart +++ b/packages/uni_app/lib/view/home/widgets/schedule_card.dart @@ -49,6 +49,9 @@ class ScheduleCard extends GenericCard { ), ), contentLoadingWidget: const ScheduleCardShimmer().build(context), + mapper: (lectures) => lectures + .where((lecture) => lecture.endTime.isAfter(DateTime.now())) + .toList(), ); } @@ -71,16 +74,8 @@ class ScheduleCard extends GenericCard { for (final dayLectures in lecturesByDay.sublist(0, min(2, lecturesByDay.length))) { final day = dayLectures.key; - final lectures = dayLectures.value - .where( - (element) => - // Hide finished lectures from today - element.startTime.weekday != DateTime.now().weekday || - element.endTime.isAfter(DateTime.now()), - ) - .toList(); - - if (lectures.isEmpty) { + + if (dayLectures.value.isEmpty) { continue; } @@ -91,11 +86,11 @@ class ScheduleCard extends GenericCard { ), ); - for (final lecture in lectures) { + for (final lecture in dayLectures.value) { rows.add(createRowFromLecture(context, lecture)); } - if (lectures.length >= 2) { + if (dayLectures.value.length >= 2) { break; } } diff --git a/packages/uni_app/lib/view/lazy_consumer.dart b/packages/uni_app/lib/view/lazy_consumer.dart index 49e9f39b6..795e249e2 100644 --- a/packages/uni_app/lib/view/lazy_consumer.dart +++ b/packages/uni_app/lib/view/lazy_consumer.dart @@ -28,6 +28,7 @@ class LazyConsumer, T2> required this.hasContent, required this.onNullContent, this.contentLoadingWidget, + this.mapper, super.key, }); @@ -35,6 +36,9 @@ class LazyConsumer, T2> final bool Function(T2) hasContent; final Widget onNullContent; final Widget? contentLoadingWidget; + final T2 Function(T2)? mapper; + + static T2 _defaultMapper(T2 value) => value; @override Widget build(BuildContext context) { @@ -89,8 +93,11 @@ class LazyConsumer, T2> } Widget requestDependantWidget(BuildContext context, T1 provider) { - final showContent = - provider.state != null && hasContent(provider.state as T2); + final mappedState = provider.state != null + ? (mapper ?? _defaultMapper)(provider.state as T2) + : null; + + final showContent = provider.state != null && hasContent(mappedState as T2); if (provider.requestStatus == RequestStatus.busy && !showContent) { return loadingWidget(context); @@ -99,7 +106,7 @@ class LazyConsumer, T2> } return showContent - ? builder(context, provider.state as T2) + ? builder(context, mappedState) : Center( child: Padding( padding: const EdgeInsets.symmetric(vertical: 10), diff --git a/packages/uni_app/pubspec.yaml b/packages/uni_app/pubspec.yaml index b6691f978..74eccf404 100644 --- a/packages/uni_app/pubspec.yaml +++ b/packages/uni_app/pubspec.yaml @@ -7,7 +7,7 @@ publish_to: "none" # We do not publish to pub.dev # To change it manually, override the value in app_version.txt. # The app version code is automatically also bumped by CI. # Do not change it manually. -version: 1.10.0-beta.25+328 +version: 1.10.0-beta.33+336 environment: sdk: ">=3.4.0 <4.0.0" diff --git a/packages/uni_ui/lib/main.dart b/packages/uni_ui/lib/main.dart new file mode 100644 index 000000000..fc1d906a2 --- /dev/null +++ b/packages/uni_ui/lib/main.dart @@ -0,0 +1,329 @@ +import 'package:flutter/material.dart'; +import 'package:uni_ui/timeline/timeline.dart'; + +void main() { + runApp(MyApp()); +} + +class MyApp extends StatelessWidget { + @override + Widget build(BuildContext context) { + return MaterialApp( + theme: ThemeData.light(), + home: Scaffold( + appBar: AppBar( + title: Text('Timeline Example'), + ), + body: Timeline( + content: [ + Container( + color: Colors.red[100], + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Content for Tab 1', + style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), + ), + SizedBox(height: 8), + Text( + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. ' + 'Suspendisse eget tincidunt sapien. Phasellus sed ligula id ' + 'turpis vulputate efficitur. Donec ut arcu vel leo blandit ' + 'dictum. Cras ut massa nisi. Nulla facilisi. Quisque porta ' + 'lobortis diam, at interdum orci.', + style: TextStyle(fontSize: 16), + ), + SizedBox(height: 16), + Text( + 'Sed ut perspiciatis unde omnis iste natus error sit voluptatem ' + 'accusantium doloremque laudantium, totam rem aperiam, eaque ' + 'ipsa quae ab illo inventore veritatis et quasi architecto ' + 'beatae vitae dicta sunt explicabo.', + style: TextStyle(fontSize: 16), + ), + SizedBox(height: 16), + Text( + 'At vero eos et accusamus et iusto odio dignissimos ducimus ' + 'qui blanditiis praesentium voluptatum deleniti atque corrupti ' + 'quos dolores et quas molestias excepturi sint occaecati ' + 'cupiditate non provident.', + style: TextStyle(fontSize: 16), + ), + ], + ), + ), + Container( + color: Colors.green[100], + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Content for Tab 2', + style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), + ), + SizedBox(height: 8), + Text( + 'Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut ' + 'odit aut fugit, sed quia consequuntur magni dolores eos qui ' + 'ratione voluptatem sequi nesciunt.', + style: TextStyle(fontSize: 16), + ), + SizedBox(height: 16), + Text( + 'Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, ' + 'consectetur, adipisci velit, sed quia non numquam eius modi ' + 'tempora incidunt ut labore et dolore magnam aliquam quaerat ' + 'voluptatem.', + style: TextStyle(fontSize: 16), + ), + SizedBox(height: 16), + Text( + 'Ut enim ad minima veniam, quis nostrum exercitationem ullam ' + 'corporis suscipit laboriosam, nisi ut aliquid ex ea commodi ' + 'consequatur?', + style: TextStyle(fontSize: 16), + ), + ], + ), + ), + Container( + color: Colors.blue[100], + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Content for Tab 3', + style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), + ), + SizedBox(height: 8), + Text( + 'Quis autem vel eum iure reprehenderit qui in ea voluptate ' + 'velit esse quam nihil molestiae consequatur, vel illum qui ' + 'dolorem eum fugiat quo voluptas nulla pariatur?', + style: TextStyle(fontSize: 16), + ), + SizedBox(height: 16), + Text( + 'But I must explain to you how all this mistaken idea of ' + 'denouncing pleasure and praising pain was born and I will ' + 'give you a complete account of the system, and expound the ' + 'actual teachings of the great explorer of the truth.', + style: TextStyle(fontSize: 16), + ), + SizedBox(height: 16), + Text( + 'Nor again is there anyone who loves or pursues or desires to ' + 'obtain pain of itself, because it is pain, but because ' + 'occasionally circumstances occur in which toil and pain can ' + 'procure him some great pleasure.', + style: TextStyle(fontSize: 16), + ), + ], + ), + ), + Container( + color: Colors.red[100], + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Content for Tab 4', + style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), + ), + SizedBox(height: 8), + Text( + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. ' + 'Suspendisse eget tincidunt sapien. Phasellus sed ligula id ' + 'turpis vulputate efficitur. Donec ut arcu vel leo blandit ' + 'dictum. Cras ut massa nisi. Nulla facilisi. Quisque porta ' + 'lobortis diam, at interdum orci.', + style: TextStyle(fontSize: 16), + ), + SizedBox(height: 16), + Text( + 'Sed ut perspiciatis unde omnis iste natus error sit voluptatem ' + 'accusantium doloremque laudantium, totam rem aperiam, eaque ' + 'ipsa quae ab illo inventore veritatis et quasi architecto ' + 'beatae vitae dicta sunt explicabo.', + style: TextStyle(fontSize: 16), + ), + SizedBox(height: 16), + Text( + 'At vero eos et accusamus et iusto odio dignissimos ducimus ' + 'qui blanditiis praesentium voluptatum deleniti atque corrupti ' + 'quos dolores et quas molestias excepturi sint occaecati ' + 'cupiditate non provident.', + style: TextStyle(fontSize: 16), + ), + ], + ), + ), + Container( + color: Colors.red[100], + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Content for Tab 5', + style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), + ), + SizedBox(height: 8), + Text( + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. ' + 'Suspendisse eget tincidunt sapien. Phasellus sed ligula id ' + 'turpis vulputate efficitur. Donec ut arcu vel leo blandit ' + 'dictum. Cras ut massa nisi. Nulla facilisi. Quisque porta ' + 'lobortis diam, at interdum orci.', + style: TextStyle(fontSize: 16), + ), + SizedBox(height: 16), + Text( + 'Sed ut perspiciatis unde omnis iste natus error sit voluptatem ' + 'accusantium doloremque laudantium, totam rem aperiam, eaque ' + 'ipsa quae ab illo inventore veritatis et quasi architecto ' + 'beatae vitae dicta sunt explicabo.', + style: TextStyle(fontSize: 16), + ), + SizedBox(height: 16), + Text( + 'At vero eos et accusamus et iusto odio dignissimos ducimus ' + 'qui blanditiis praesentium voluptatum deleniti atque corrupti ' + 'quos dolores et quas molestias excepturi sint occaecati ' + 'cupiditate non provident.', + style: TextStyle(fontSize: 16), + ), + ], + ), + ), + Container( + color: Colors.red[100], + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Content for Tab 6', + style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), + ), + SizedBox(height: 8), + Text( + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. ' + 'Suspendisse eget tincidunt sapien. Phasellus sed ligula id ' + 'turpis vulputate efficitur. Donec ut arcu vel leo blandit ' + 'dictum. Cras ut massa nisi. Nulla facilisi. Quisque porta ' + 'lobortis diam, at interdum orci.', + style: TextStyle(fontSize: 16), + ), + SizedBox(height: 16), + Text( + 'Sed ut perspiciatis unde omnis iste natus error sit voluptatem ' + 'accusantium doloremque laudantium, totam rem aperiam, eaque ' + 'ipsa quae ab illo inventore veritatis et quasi architecto ' + 'beatae vitae dicta sunt explicabo.', + style: TextStyle(fontSize: 16), + ), + SizedBox(height: 16), + Text( + 'At vero eos et accusamus et iusto odio dignissimos ducimus ' + 'qui blanditiis praesentium voluptatum deleniti atque corrupti ' + 'quos dolores et quas molestias excepturi sint occaecati ' + 'cupiditate non provident.', + style: TextStyle(fontSize: 16), + ), + ], + ), + ), + Container( + color: Colors.red[100], + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Content for Tab 7', + style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), + ), + SizedBox(height: 8), + Text( + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. ' + 'Suspendisse eget tincidunt sapien. Phasellus sed ligula id ' + 'turpis vulputate efficitur. Donec ut arcu vel leo blandit ' + 'dictum. Cras ut massa nisi. Nulla facilisi. Quisque porta ' + 'lobortis diam, at interdum orci.', + style: TextStyle(fontSize: 16), + ), + SizedBox(height: 16), + Text( + 'Sed ut perspiciatis unde omnis iste natus error sit voluptatem ' + 'accusantium doloremque laudantium, totam rem aperiam, eaque ' + 'ipsa quae ab illo inventore veritatis et quasi architecto ' + 'beatae vitae dicta sunt explicabo.', + style: TextStyle(fontSize: 16), + ), + SizedBox(height: 16), + Text( + 'At vero eos et accusamus et iusto odio dignissimos ducimus ' + 'qui blanditiis praesentium voluptatum deleniti atque corrupti ' + 'quos dolores et quas molestias excepturi sint occaecati ' + 'cupiditate non provident.', + style: TextStyle(fontSize: 16), + ), + ], + ), + ), + ], + tabs: [ + Column( + children: [ + Text('Mon'), + Text('1'), + ], + ), + Column( + children: [ + Text('Tue'), + Text('2'), + ], + ), + Column( + children: [ + Text('Wed'), + Text('3'), + ], + ), + Column( + children: [ + Text('Thu'), + Text('4'), + ], + ), + Column( + children: [ + Text('Fri'), + Text('5'), + ], + ), + Column( + children: [ + Text('Sat'), + Text('6'), + ], + ), + Column( + children: [ + Text('Sun'), + Text('7'), + ], + ), + ], + ), + ), + ); + } +} diff --git a/packages/uni_ui/lib/navbar/bottom_navbar.dart b/packages/uni_ui/lib/navbar/bottom_navbar.dart new file mode 100644 index 000000000..da84cc14e --- /dev/null +++ b/packages/uni_ui/lib/navbar/bottom_navbar.dart @@ -0,0 +1,108 @@ +import 'package:figma_squircle/figma_squircle.dart'; +import 'package:flutter/material.dart'; +import 'package:uni_ui/navbar/bottom_navbar_item.dart'; + +class _BottomNavbarContainer extends StatelessWidget { + _BottomNavbarContainer({required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + return Container( + height: 80, + margin: EdgeInsets.only(left: 20, right: 20, bottom: 20), + decoration: ShapeDecoration( + color: Theme.of(context).colorScheme.primary, + shape: SmoothRectangleBorder( + borderRadius: SmoothBorderRadius( + cornerRadius: 20, + cornerSmoothing: 1, + )), + shadows: [ + BoxShadow( + color: Theme.of(context).colorScheme.shadow.withAlpha(0x7f), + blurRadius: 5, + offset: Offset(0, 3), + ), + ], + ), + child: ClipSmoothRect( + radius: SmoothBorderRadius( + cornerRadius: 20, + cornerSmoothing: 1, + ), + child: Container( + decoration: BoxDecoration( + gradient: RadialGradient( + colors: [ + Theme.of(context).colorScheme.tertiary.withAlpha(0x3f), + Colors.transparent, + ], + center: Alignment(-0.5, -1.1), + radius: 2.5, + ), + ), + child: Container( + decoration: BoxDecoration( + gradient: RadialGradient( + colors: [ + Theme.of(context).colorScheme.tertiary.withAlpha(0x3f), + Colors.transparent, + ], + center: Alignment.bottomRight, + radius: 2.5, + )), + child: child, + ), + ), + ), + ); + } +} + +class BottomNavbar extends StatefulWidget { + BottomNavbar({super.key, required this.items}); + + final List items; + + @override + _BottomNavbarState createState() => _BottomNavbarState(); +} + +class _BottomNavbarState extends State { + void _refresh() { + setState(() {}); + } + + void _onTap(int index) { + widget.items[index].onTap(); + _refresh(); + } + + @override + Widget build(BuildContext context) { + return _BottomNavbarContainer( + child: Theme( + data: Theme.of(context).copyWith( + splashColor: Theme.of(context).colorScheme.tertiary.withAlpha(0x1f), + highlightColor: Colors.transparent, + ), + child: BottomNavigationBar( + onTap: _onTap, + backgroundColor: Colors.transparent, + elevation: 0, + iconSize: 32, + type: BottomNavigationBarType.fixed, + items: widget.items + .map((item) => item.toBottomNavigationBarItem(context)) + .toList(), + selectedFontSize: 0, + unselectedFontSize: 0, + showSelectedLabels: false, + showUnselectedLabels: false, + ), + ), + ); + } +} diff --git a/packages/uni_ui/lib/navbar/bottom_navbar_item.dart b/packages/uni_ui/lib/navbar/bottom_navbar_item.dart new file mode 100644 index 000000000..e34db91bf --- /dev/null +++ b/packages/uni_ui/lib/navbar/bottom_navbar_item.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; +import 'package:phosphor_flutter/phosphor_flutter.dart'; + +abstract class NavbarDefaultIcons { + static final home = PhosphorIcons.house(PhosphorIconsStyle.duotone); + static final academic = + PhosphorIcons.graduationCap(PhosphorIconsStyle.duotone); + static final restaurant = PhosphorIcons.forkKnife(PhosphorIconsStyle.duotone); + static final faculty = PhosphorIcons.buildings(PhosphorIconsStyle.duotone); + static final map = PhosphorIcons.mapTrifold(PhosphorIconsStyle.duotone); +} + +class BottomNavbarItem { + BottomNavbarItem({ + required this.icon, + required this.isSelected, + required this.onTap, + }); + + final IconData icon; + final bool Function() isSelected; + final void Function() onTap; + + BottomNavigationBarItem toBottomNavigationBarItem(BuildContext context) { + return BottomNavigationBarItem( + icon: Container( + padding: EdgeInsets.all(6), + decoration: isSelected() + ? BoxDecoration( + color: Theme.of(context).colorScheme.tertiary.withAlpha(0x2f), + borderRadius: BorderRadius.circular(10), + ) + : null, + child: PhosphorIcon( + icon, + color: Theme.of(context).colorScheme.secondary, + ), + ), + label: '', + ); + } +} diff --git a/packages/uni_ui/lib/service_card.dart b/packages/uni_ui/lib/service_card.dart new file mode 100644 index 000000000..2628c7f1e --- /dev/null +++ b/packages/uni_ui/lib/service_card.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; +import 'package:phosphor_flutter/phosphor_flutter.dart'; +import 'package:uni_ui/generic_card.dart'; + +class ServiceCard extends StatelessWidget { + const ServiceCard({ + super.key, + required this.name, + required this.openingHours, + }); + + final String name; + final List openingHours; + + @override + Widget build(BuildContext context) { + return GenericCard( + key: key, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + name, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.headlineMedium!, + ), + ], + ), + const SizedBox(height: 5), + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + PhosphorIcon( + PhosphorIcons.clock(PhosphorIconsStyle.duotone), + color: Theme.of(context).textTheme.bodyMedium!.color, + size: 20, + ), + const SizedBox(width: 5), + Column( + children: openingHours.map((hour) { + return Text( + hour, + style: Theme.of(context).textTheme.bodySmall, + ); + }).toList(), + ) + ], + ), + ], + ), + ); + } +} diff --git a/packages/uni_ui/lib/timeline/timeline.dart b/packages/uni_ui/lib/timeline/timeline.dart new file mode 100644 index 000000000..e4c2e2a20 --- /dev/null +++ b/packages/uni_ui/lib/timeline/timeline.dart @@ -0,0 +1,135 @@ +import 'package:figma_squircle/figma_squircle.dart'; +import 'package:flutter/material.dart'; +import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; + +class Timeline extends StatefulWidget { + const Timeline({ + required this.tabs, + required this.content, + super.key, + }); + + final List tabs; + final List content; + + @override + State createState() => _TimelineState(); +} + +class _TimelineState extends State { + int _currentIndex = 0; + final ItemScrollController _itemScrollController = ItemScrollController(); + final ItemPositionsListener _itemPositionsListener = + ItemPositionsListener.create(); + final ScrollController _tabScrollController = ScrollController(); + final List _tabKeys = []; + + @override + void initState() { + super.initState(); + + _tabKeys.addAll(List.generate(widget.tabs.length, (index) => GlobalKey())); + + _itemPositionsListener.itemPositions.addListener(() { + final positions = _itemPositionsListener.itemPositions.value; + if (positions.isNotEmpty) { + final firstVisibleIndex = positions + .where((ItemPosition position) => position.itemLeadingEdge >= 0) + .reduce((ItemPosition current, ItemPosition next) => + current.itemLeadingEdge < next.itemLeadingEdge ? current : next) + .index; + + if (_currentIndex != firstVisibleIndex) { + setState(() { + _currentIndex = firstVisibleIndex; + }); + + _scrollToCenterTab(firstVisibleIndex); + } + } + }); + } + + void _onTabTapped(int index) { + _itemScrollController.scrollTo( + index: index, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + _scrollToCenterTab(index); + } + + void _scrollToCenterTab(int index) { + final screenWidth = MediaQuery.of(context).size.width; + final RenderBox tabBox = + _tabKeys[index].currentContext!.findRenderObject() as RenderBox; + + final tabWidth = tabBox.size.width; + final offset = (_tabScrollController.offset + + tabBox.localToGlobal(Offset.zero).dx + + (tabWidth / 2) - + (screenWidth / 2)) + .clamp( + 0.0, + _tabScrollController.position.maxScrollExtent, + ); + + _tabScrollController.animateTo( + offset, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + SingleChildScrollView( + scrollDirection: Axis.horizontal, + controller: _tabScrollController, + child: Row( + children: widget.tabs.asMap().entries.map((entry) { + int index = entry.key; + Widget tab = entry.value; + return GestureDetector( + onTap: () => _onTabTapped(index), + child: Padding( + padding: const EdgeInsets.all(7.0), + child: ClipSmoothRect( + radius: SmoothBorderRadius( + cornerRadius: 10, + cornerSmoothing: 1, + ), + child: Container( + key: _tabKeys[index], + padding: const EdgeInsets.symmetric( + vertical: 10.0, horizontal: 15.0), + color: _currentIndex == index + ? Theme.of(context) + .colorScheme + .tertiary + .withOpacity(0.25) + : Colors.transparent, + child: tab, + ), + ), + ), + ); + }).toList(), + ), + ), + Expanded( + child: ScrollablePositionedList.builder( + itemCount: widget.content.length, + itemScrollController: _itemScrollController, + itemPositionsListener: _itemPositionsListener, + itemBuilder: (context, index) { + return widget.content[index]; + }, + ), + ), + ], + ); + } +} diff --git a/packages/uni_ui/pubspec.lock b/packages/uni_ui/pubspec.lock index 1f09ca040..20675b329 100644 --- a/packages/uni_ui/pubspec.lock +++ b/packages/uni_ui/pubspec.lock @@ -403,6 +403,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.27.7" + scrollable_positioned_list: + dependency: "direct main" + description: + name: scrollable_positioned_list + sha256: "1b54d5f1329a1e263269abc9e2543d90806131aa14fe7c6062a8054d57249287" + url: "https://pub.dev" + source: hosted + version: "0.3.8" shelf: dependency: transitive description: diff --git a/packages/uni_ui/pubspec.yaml b/packages/uni_ui/pubspec.yaml index 71d7cbb74..dbfd71135 100644 --- a/packages/uni_ui/pubspec.yaml +++ b/packages/uni_ui/pubspec.yaml @@ -12,6 +12,7 @@ dependencies: flutter: sdk: flutter phosphor_flutter: ^2.1.0 + scrollable_positioned_list: ^0.3.5 dev_dependencies: custom_lint: ^0.6.4