diff --git a/lib/components/android/common/IconWithLabel.dart b/lib/components/android/common/IconWithLabel.dart new file mode 100644 index 00000000..1824e69f --- /dev/null +++ b/lib/components/android/common/IconWithLabel.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; + +class iconWithName extends StatelessWidget { + final IconData icon; + final String name; + final bool isVertical; + final Color color; + final double size; + final Color backgroundColor; + final BorderRadius? borderRadius; + final Color TextColor; + final double fontSize; + final bool isGapped; + const iconWithName({ + super.key, + required this.icon, + required this.name, + this.isVertical = true, + this.color = Colors.black, + this.size = 16.0, + this.fontSize = 12.0, + this.backgroundColor = Colors.white, + this.borderRadius, + this.TextColor = Colors.black, + this.isGapped = true, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6), + height: 20, + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: borderRadius, + ), + child: isVertical + ? Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: size, color: color), + const SizedBox(height: 8.0), + Text(name, style: const TextStyle(fontSize: 14.0)), + ], + ) + : Center( + child: Row( + children: [ + Icon(icon, size: size, color: color), + SizedBox(width: isGapped ? 2.0 : 0.0), + Text(name, + style: TextStyle( + fontSize: fontSize, + color: TextColor, + fontWeight: FontWeight.bold)), + ], + ), + ), + ); + } +} diff --git a/lib/components/android/common/expandable_page_view.dart b/lib/components/android/common/expandable_page_view.dart new file mode 100644 index 00000000..ce9b49f9 --- /dev/null +++ b/lib/components/android/common/expandable_page_view.dart @@ -0,0 +1,138 @@ +import 'package:flutter/material.dart'; + +class ExpandablePageView extends StatefulWidget { + final int itemCount; + final Widget Function(BuildContext, int) itemBuilder; + final PageController? controller; + final ValueChanged? onPageChanged; + final bool reverse; + final double defaultHeight; + + const ExpandablePageView({ + required this.itemCount, + required this.itemBuilder, + this.controller, + this.onPageChanged, + this.reverse = false, + this.defaultHeight = 1750, + super.key, + }); + + @override + _ExpandablePageViewState createState() => _ExpandablePageViewState(); +} + +class _ExpandablePageViewState extends State { + PageController? _pageController; + late List _heights; + int _currentPage = 0; + + double get _currentHeight => _heights[_currentPage]; + + @override + void initState() { + super.initState(); + _heights = + List.filled(widget.itemCount, widget.defaultHeight, growable: true); + _pageController = widget.controller ?? PageController(); + _pageController?.addListener(_updatePage); + } + + @override + void dispose() { + _pageController?.removeListener(_updatePage); + _pageController?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return TweenAnimationBuilder( + curve: Curves.easeInOutCubic, + tween: Tween(begin: _heights.first, end: _currentHeight), + duration: const Duration(milliseconds: 300), + builder: (context, value, child) => SizedBox(height: value, child: child), + child: PageView.builder( + controller: _pageController, + itemCount: widget.itemCount, + physics: const BouncingScrollPhysics(), + itemBuilder: _itemBuilder, + onPageChanged: widget.onPageChanged, + reverse: widget.reverse, + ), + ); + } + + Widget _itemBuilder(BuildContext context, int index) { + final item = widget.itemBuilder(context, index); + return OverflowBox( + minHeight: 0, + maxHeight: double.infinity, + alignment: Alignment.topCenter, + child: SizeReportingWidget( + onSizeChange: (size) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + setState(() { + if ((size.height - widget.defaultHeight).abs() > 10) { + _heights[index] = size.height; + } + }); + } + }); + }, + child: item, + ), + ); + } + + void _updatePage() { + final newPage = _pageController?.page?.round(); + if (_currentPage != newPage) { + // Use post-frame callback to defer state update + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + setState(() { + _currentPage = newPage ?? _currentPage; + }); + } + }); + } + } +} + +class SizeReportingWidget extends StatefulWidget { + final Widget child; + final ValueChanged onSizeChange; + + const SizeReportingWidget({ + required this.child, + required this.onSizeChange, + super.key, + }); + + @override + _SizeReportingWidgetState createState() => _SizeReportingWidgetState(); +} + +class _SizeReportingWidgetState extends State { + Size? _oldSize; + + @override + Widget build(BuildContext context) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _notifySize(); + }); + return widget.child; + } + + void _notifySize() { + if (mounted) { + final size = context.size; + if (_oldSize != size) { + _oldSize = size; + if (size != null) widget.onSizeChange(size); + } + } + } +} diff --git a/lib/components/android/common/reusable_carousel.dart b/lib/components/android/common/reusable_carousel.dart new file mode 100644 index 00000000..48fbf00e --- /dev/null +++ b/lib/components/android/common/reusable_carousel.dart @@ -0,0 +1,359 @@ +// ignore_for_file: must_be_immutable + +import 'dart:math'; + +import 'package:aurora/components/android/helper/scroll_helper.dart'; +import 'package:aurora/pages/Android/Anime/details_page.dart'; +import 'package:aurora/pages/Android/Manga/details_page.dart'; +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:hive_flutter/hive_flutter.dart'; +import 'package:iconsax/iconsax.dart'; +import 'package:shimmer/shimmer.dart'; +import 'package:transformable_list_view/transformable_list_view.dart'; + +class ReusableCarousel extends StatelessWidget { + dynamic carouselData; + final String? title; + final String? tag; + final bool? detailsPage; + final bool? secondary; + final bool isManga; + + ReusableCarousel({ + super.key, + this.title, + this.carouselData, + this.tag, + this.secondary = true, + this.detailsPage = false, + this.isManga = false, + }); + + final ScrollDirectionHelper _scrollDirectionHelper = ScrollDirectionHelper(); + + @override + Widget build(BuildContext context) { + final customScheme = Theme.of(context).colorScheme; + if (carouselData == null || carouselData!.isEmpty) { + return const SizedBox.shrink(); + } + + return ValueListenableBuilder( + valueListenable: Hive.box('app-data').listenable(), + builder: (context, Box box, _) { + final bool usingCompactCards = + box.get('usingCompactCards', defaultValue: false); + final bool usingSaikouCards = + box.get('usingSaikouCards', defaultValue: true); + final double cardRoundness = + box.get('cardRoundness', defaultValue: 18.0); + + return normalCard(customScheme, context, usingCompactCards, + usingSaikouCards, cardRoundness); + }, + ); + } + + Column normalCard(ColorScheme customScheme, BuildContext context, + bool usingCompactCards, bool usingSaikouCards, double cardRoundness) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20.0), + child: Row( + children: [ + Text( + title ?? '??', + style: TextStyle( + fontSize: 16, + fontFamily: 'Poppins', + fontWeight: FontWeight.bold, + color: customScheme.primary, + ), + ), + secondary! + ? Text( + isManga ? ' Manga' : ' Animes', + style: const TextStyle( + fontSize: 16, fontWeight: FontWeight.w500), + ) + : const SizedBox.shrink(), + const Expanded(child: SizedBox.shrink()), + const Icon(Icons.arrow_right) + ], + ), + ), + const SizedBox(height: 15), + SizedBox( + height: usingSaikouCards + ? (usingCompactCards ? 180 : 210) + : (usingCompactCards ? 280 : 300), + child: TransformableListView.builder( + padding: const EdgeInsets.only(left: 20), + physics: const BouncingScrollPhysics( + decelerationRate: ScrollDecelerationRate.fast), + getTransformMatrix: getTransformMatrix, + scrollDirection: Axis.horizontal, + itemCount: carouselData!.length, + itemExtent: MediaQuery.of(context).size.width / + (usingSaikouCards ? 3.3 : 2.3), + itemBuilder: (context, index) { + dynamic itemData = detailsPage! + ? title!.contains('Related') + ? carouselData[index]['node'] + : carouselData![index]['node']['mediaRecommendation'] + : carouselData[index]; + final String posterUrl = itemData['coverImage']['large'] ?? '??'; + final String animeTitle = itemData['title']['english'] ?? + itemData['title']['romaji'] ?? + '?'; + final random = Random().nextInt(100000); + final tagg = '${itemData['id']}$tag$random'; + String extraData = + ((itemData['averageScore'] ?? 0) / 10)?.toString() ?? '??'; + + return Padding( + padding: const EdgeInsets.only(right: 10.0), + child: GestureDetector( + onTap: () { + bool isTypeManga = + itemData['type']?.toString().toLowerCase() == 'manga'; + + if (isTypeManga || (itemData['type'] == null && isManga)) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => MangaDetailsPage( + id: itemData['id'], + posterUrl: posterUrl, + tag: tagg, + ), + ), + ); + } else { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => DetailsPage( + id: itemData['id'], + posterUrl: posterUrl, + tag: tagg, + ), + ), + ); + } + }, + child: Column( + children: [ + Stack( + children: [ + Stack(children: [ + SizedBox( + height: usingSaikouCards ? 160 : 240, + child: Hero( + tag: tagg, + child: ClipRRect( + borderRadius: + BorderRadius.circular(cardRoundness), + child: CachedNetworkImage( + imageUrl: posterUrl, + placeholder: (context, url) => + Shimmer.fromColors( + baseColor: Colors.grey[900]!, + highlightColor: Colors.grey[700]!, + child: Container( + color: Colors.grey[900], + width: double.infinity, + ), + ), + fit: BoxFit.cover, + width: double.infinity, + height: usingSaikouCards + ? (usingCompactCards ? 200 : 170) + : (usingCompactCards ? 280 : 250), + ), + ), + ), + ), + if (!usingCompactCards) + Positioned( + bottom: 0, + right: 0, + child: Container( + padding: const EdgeInsets.symmetric( + vertical: 5, horizontal: 8), + decoration: BoxDecoration( + color: Theme.of(context) + .colorScheme + .surfaceContainer, + borderRadius: BorderRadius.only( + topLeft: Radius.circular( + cardRoundness - 5), + bottomRight: Radius.circular( + cardRoundness))), + child: Row( + children: [ + Icon( + Iconsax.star5, + color: Theme.of(context) + .colorScheme + .primary, + size: 14, + ), + const SizedBox(width: 2), + Text( + extraData, + style: TextStyle( + fontSize: 11, + fontFamily: 'Poppins-Bold', + color: Theme.of(context) + .colorScheme + .inverseSurface), + ), + ], + ), + )), + if (usingCompactCards) + Positioned( + top: 0, + right: 0, + child: Container( + padding: const EdgeInsets.symmetric( + vertical: 4, horizontal: 12), + decoration: BoxDecoration( + color: Theme.of(context) + .colorScheme + .surfaceContainer, + borderRadius: BorderRadius.only( + bottomLeft: + Radius.circular(cardRoundness), + topRight: Radius.circular( + cardRoundness - 5))), + child: Text( + extraData, + style: TextStyle( + fontFamily: 'Poppins-Bold', + fontSize: 11, + color: Theme.of(context) + .colorScheme + .inverseSurface), + ), + )), + ]), + if (usingCompactCards) + Positioned( + bottom: 0, + left: 0, + right: 0, + child: Container( + height: 60, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + gradient: LinearGradient( + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + colors: [ + Colors.black.withOpacity(0.8), + Colors.transparent, + ], + ), + ), + ), + ), + if (usingCompactCards) + Positioned( + bottom: 10, + left: 10, + right: 10, + child: Text( + animeTitle, + style: TextStyle( + color: Theme.of(context) + .colorScheme + .inverseSurface, + fontSize: usingSaikouCards ? 10 : 13, + fontWeight: FontWeight.bold, + shadows: [ + Shadow( + blurRadius: 4, + color: Colors.black.withOpacity(0.7), + offset: const Offset(2, 2), + ), + ], + ), + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ) + else + Column( + children: [ + SizedBox( + height: usingSaikouCards ? 164 : 248, + width: MediaQuery.of(context).size.width / + (usingSaikouCards ? 3.3 : 2.3), + ), + Text( + animeTitle, + style: TextStyle( + color: Theme.of(context) + .colorScheme + .inverseSurface, + fontSize: usingSaikouCards ? 10 : 13, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ], + ), + const SizedBox(height: 8), + ], + ), + ), + ); + }, + ), + ), + ], + ); + } + + Matrix4 getTransformMatrix(TransformableListItem item) { + const maxScale = 1; + const minScale = 0.9; + final viewportWidth = item.constraints.viewportMainAxisExtent; + final itemLeftEdge = item.offset.dx; + final itemRightEdge = item.offset.dx + item.size.width; + + bool isScrollingRight = + _scrollDirectionHelper.isScrollingRight(item.offset); + + double visiblePortion; + if (isScrollingRight) { + visiblePortion = (viewportWidth - itemLeftEdge) / item.size.width; + } else { + visiblePortion = (itemRightEdge) / item.size.width; + } + + if ((isScrollingRight && itemLeftEdge < viewportWidth) || + (!isScrollingRight && itemRightEdge > 0)) { + const scaleRange = maxScale - minScale; + final scale = + minScale + (scaleRange * visiblePortion).clamp(0.0, scaleRange); + + return Matrix4.identity() + ..translate(item.size.width / 2, 0, 0) + ..scale(scale) + ..translate(-item.size.width / 2, 0, 0); + } + + return Matrix4.identity(); + } +} diff --git a/lib/components/android/common/settings_modal.dart b/lib/components/android/common/settings_modal.dart new file mode 100644 index 00000000..ba7a7299 --- /dev/null +++ b/lib/components/android/common/settings_modal.dart @@ -0,0 +1,142 @@ +import 'package:aurora/auth/auth_provider.dart'; +import 'package:aurora/main.dart'; +import 'package:aurora/pages/Android/Downloads/download_page.dart'; +import 'package:aurora/pages/Android/Rescue/Anime/home_page.dart'; +import 'package:aurora/pages/Android/user/profile.dart'; +import 'package:aurora/pages/Android/user/settings.dart'; +import 'package:flutter/material.dart'; +import 'package:iconsax/iconsax.dart'; +import 'package:provider/provider.dart'; + +class SettingsModal extends StatelessWidget { + const SettingsModal({ + super.key, + }); + + @override + Widget build(BuildContext context) { + final anilistProvider = Provider.of(context); + final userName = anilistProvider.userData?['user']?['name'] ?? 'Guest'; + final avatarImagePath = + anilistProvider.userData?['user']?['avatar']?['large']; + final isLoggedIn = anilistProvider.userData?['user']?['name'] != null; + return Container( + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row(children: [ + const SizedBox(width: 5), + CircleAvatar( + radius: 24, + backgroundColor: Theme.of(context).colorScheme.surfaceContainer, + child: isLoggedIn + ? ClipRRect( + borderRadius: BorderRadius.circular(50), + child: Image.network(fit: BoxFit.cover, avatarImagePath), + ) + : Icon( + Icons.person, + color: Theme.of(context).colorScheme.inverseSurface, + ), + ), + const SizedBox(width: 15), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(userName), + GestureDetector( + onTap: () { + if (isLoggedIn) { + anilistProvider.logout(context); + } else { + anilistProvider.login(context); + } + Navigator.pushAndRemoveUntil( + context, + MaterialPageRoute(builder: (context) => const MainApp()), + (route) => false, + ); + }, + child: Text( + isLoggedIn ? 'Logout' : 'Login', + style: TextStyle( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.bold), + ), + ), + ], + ), + const Expanded( + child: SizedBox.shrink(), + ), + IconButton( + onPressed: () {}, + style: ElevatedButton.styleFrom( + backgroundColor: + Theme.of(context).colorScheme.surfaceContainerHighest, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20))), + icon: const Icon(Iconsax.notification)) + ]), + const SizedBox(height: 10), + ListTile( + leading: const Icon(Iconsax.user), + title: const Text('View Profile'), + onTap: () { + Navigator.pushReplacement( + context, + MaterialPageRoute(builder: (context) => const ProfilePage()), + ); + }, + ), + ListTile( + leading: const Icon(Iconsax.toggle_off_circle), + title: const Text('Rescue Mode'), + onTap: () { + Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (context) => const RescueAnimeHome()), + ); + }, + ), + ListTile( + leading: const Icon(Iconsax.document_download), + title: const Text('Downloads'), + onTap: () { + Navigator.pushReplacement( + context, + MaterialPageRoute(builder: (context) => const DownloadPage()), + ); + }, + ), + ListTile( + leading: const Icon(Iconsax.setting), + title: const Text('Settings'), + onTap: () { + Navigator.pushReplacement( + context, + MaterialPageRoute(builder: (context) => const SettingsPage()), + ); + }, + ), + if (isLoggedIn) + ListTile( + leading: const Icon(Iconsax.logout), + title: const Text('Logout'), + onTap: () { + Provider.of(context, listen: false) + .logout(context); + Navigator.pushAndRemoveUntil( + context, + MaterialPageRoute(builder: (context) => const MainApp()), + (route) => false, + ); + }, + ), + ], + ), + ); + } +} diff --git a/lib/components/android/common/switch_tile.dart b/lib/components/android/common/switch_tile.dart new file mode 100644 index 00000000..2bc390cc --- /dev/null +++ b/lib/components/android/common/switch_tile.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart'; + +class SwitchTile extends StatefulWidget { + final IconData icon; + final String title; + final String description; + final bool? defaultValue; + final VoidCallback? onTap; + + const SwitchTile({ + super.key, + required this.icon, + required this.title, + required this.description, + this.defaultValue, + this.onTap, + }); + + @override + _SwitchTileState createState() => _SwitchTileState(); +} + +class _SwitchTileState extends State { + late bool _isSwitched; + + @override + void initState() { + super.initState(); + _isSwitched = + widget.defaultValue ?? false; + } + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: widget.onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20.0, vertical: 10.0), + child: Row( + children: [ + Icon(widget.icon, + size: 30, color: Theme.of(context).colorScheme.primary), + const SizedBox(width: 20), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.title, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + const SizedBox(height: 5), + Text( + widget.description, + style: TextStyle( + fontSize: 14, + color: Theme.of(context) + .colorScheme + .onSurface + .withOpacity(0.6), + ), + ), + ], + ), + ), + Switch( + value: _isSwitched, + onChanged: (value) { + setState(() { + _isSwitched = value; + }); + }, + activeColor: Theme.of(context).colorScheme.primary, + inactiveThumbColor: + Theme.of(context).colorScheme.onSurface.withOpacity(0.6), + inactiveTrackColor: + Theme.of(context).colorScheme.onSurface.withOpacity(0.3), + ), + ], + ), + ), + ); + } +} diff --git a/lib/components/android/common/switch_tile_stateless.dart b/lib/components/android/common/switch_tile_stateless.dart new file mode 100644 index 00000000..72ffdf10 --- /dev/null +++ b/lib/components/android/common/switch_tile_stateless.dart @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart'; + +class SwitchTileStateless extends StatelessWidget { + final IconData icon; + final String title; + final String description; + final bool value; + final ValueChanged onChanged; + final VoidCallback? onTap; + + const SwitchTileStateless({ + super.key, + required this.icon, + required this.title, + required this.description, + required this.value, + required this.onChanged, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20.0, vertical: 10.0), + child: Row( + children: [ + Icon(icon, size: 30, color: Theme.of(context).colorScheme.primary), + const SizedBox(width: 20), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + const SizedBox(height: 5), + Text( + description, + style: TextStyle( + fontSize: 14, + color: Theme.of(context) + .colorScheme + .onSurface + .withOpacity(0.6), + ), + ), + ], + ), + ), + Switch( + value: value, + onChanged: onChanged, + activeColor: Theme.of(context).colorScheme.primary, + inactiveThumbColor: Theme.of(context).colorScheme.onSurface.withOpacity(0.6), + inactiveTrackColor: Theme.of(context).colorScheme.onSurface.withOpacity(0.3), + ), + ], + ), + ), + ); + } +} diff --git a/lib/components/android/common/tab_bar.dart b/lib/components/android/common/tab_bar.dart new file mode 100644 index 00000000..0621b52b --- /dev/null +++ b/lib/components/android/common/tab_bar.dart @@ -0,0 +1,60 @@ +// ignore_for_file: prefer_const_constructors + +import 'package:custom_sliding_segmented_control/custom_sliding_segmented_control.dart'; +import 'package:flutter/material.dart'; + +typedef ButtonTapCallback = void Function(int? index); + +class MyTabBar extends StatefulWidget { + final ButtonTapCallback onTap; + const MyTabBar({super.key, required this.onTap}); + + @override + State createState() => _MyTabBarState(); +} + +class _MyTabBarState extends State { + int currentIndex = 1; + @override + Widget build(BuildContext context) { + return Center( + child: CustomSlidingSegmentedControl( + fixedWidth: MediaQuery.of(context).size.width / 2 - 12, + initialValue: 1, + children: { + 1: Text('Sub', style: TextStyle(color: currentIndex == 1 ? Theme.of(context).colorScheme.inverseSurface == Theme.of(context).colorScheme.onPrimaryFixedVariant ? Colors.black : +Theme.of(context).colorScheme.onPrimaryFixedVariant == Color(0xffe2e2e2) ? Colors.black : Colors.white : null), ), + 2: Text('Dub', style: TextStyle(color: currentIndex == 2 ? Theme.of(context).colorScheme.inverseSurface == Theme.of(context).colorScheme.onPrimaryFixedVariant ? Colors.black : +Theme.of(context).colorScheme.onPrimaryFixedVariant == Color(0xffe2e2e2) ? Colors.black : Colors.white : null), ), + }, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainer, + borderRadius: BorderRadius.circular(8), + ), + thumbDecoration: BoxDecoration( + color: Theme.of(context).colorScheme.onPrimaryFixedVariant, + borderRadius: BorderRadius.circular(6), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(.3), + blurRadius: 4.0, + spreadRadius: 1.0, + offset: const Offset( + 0.0, + 2.0, + ), + ), + ], + ), + duration: Duration(milliseconds: 300), + curve: Curves.easeInToLinear, + onValueChanged: (v) => { + setState(() { + currentIndex = v; + }), + widget.onTap(v) + }, + ), + ); + } +} diff --git a/lib/components/android/common/toast.dart b/lib/components/android/common/toast.dart new file mode 100644 index 00000000..40d56979 --- /dev/null +++ b/lib/components/android/common/toast.dart @@ -0,0 +1,15 @@ +import 'package:flutter/material.dart'; + +void showToast(BuildContext context, String message) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + backgroundColor: Theme.of(context).colorScheme.surfaceContainer, + content: Text( + message, + style: TextStyle(color: Theme.of(context).colorScheme.primary), + ), + ), + ); +} diff --git a/lib/components/android/helper/scroll_helper.dart b/lib/components/android/helper/scroll_helper.dart new file mode 100644 index 00000000..82b84101 --- /dev/null +++ b/lib/components/android/helper/scroll_helper.dart @@ -0,0 +1,16 @@ +import 'package:flutter/material.dart'; + +class ScrollDirectionHelper { + Offset? previousOffset; + + bool isScrollingRight(Offset currentOffset) { + if (previousOffset == null) { + previousOffset = currentOffset; + return true; + } + + bool scrollingRight = currentOffset.dx > previousOffset!.dx; + previousOffset = currentOffset; + return scrollingRight; + } +} diff --git a/lib/components/android/home/homepage_carousel.dart b/lib/components/android/home/homepage_carousel.dart new file mode 100644 index 00000000..dbe47c73 --- /dev/null +++ b/lib/components/android/home/homepage_carousel.dart @@ -0,0 +1,318 @@ +import 'dart:math'; + +import 'package:aurora/components/android/helper/scroll_helper.dart'; +import 'package:aurora/pages/Android/Anime/details_page.dart'; +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:hive/hive.dart'; +import 'package:shimmer/shimmer.dart'; +import 'package:transformable_list_view/transformable_list_view.dart'; + +class HomepageCarousel extends StatelessWidget { + final List? carouselData; + final String? title; + final String? tag; + HomepageCarousel({super.key, this.title, this.carouselData, this.tag}); + + final ScrollDirectionHelper _scrollDirectionHelper = ScrollDirectionHelper(); + + @override + Widget build(BuildContext context) { + final bool usingCompactCards = + Hive.box('app-data').get('usingCompactCards', defaultValue: false); + final bool usingSaikouCards = + Hive.box('app-data').get('usingSaikouCards', defaultValue: true); + if (carouselData == null || carouselData!.isEmpty) { + return const SizedBox.shrink(); + } + + Matrix4 getTransformMatrix(TransformableListItem item) { + const maxScale = 1; + const minScale = 0.9; + final viewportWidth = item.constraints.viewportMainAxisExtent; + final itemLeftEdge = item.offset.dx; + final itemRightEdge = item.offset.dx + item.size.width; + + bool isScrollingRight = + _scrollDirectionHelper.isScrollingRight(item.offset); + + double visiblePortion; + if (isScrollingRight) { + visiblePortion = (viewportWidth - itemLeftEdge) / item.size.width; + } else { + visiblePortion = (itemRightEdge) / item.size.width; + } + + if ((isScrollingRight && itemLeftEdge < viewportWidth) || + (!isScrollingRight && itemRightEdge > 0)) { + const scaleRange = maxScale - minScale; + final scale = + minScale + (scaleRange * visiblePortion).clamp(0.0, scaleRange); + + return Matrix4.identity() + ..translate(item.size.width / 2, 0, 0) + ..scale(scale) + ..translate(-item.size.width / 2, 0, 0); + } + + return Matrix4.identity(); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 10), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20.0), + child: Text( + title ?? '??', + style: TextStyle( + fontSize: 16, + fontFamily: 'Poppins', + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.primary, + ), + ), + ), + const SizedBox(height: 10), + SizedBox( + height: usingSaikouCards + ? (usingCompactCards ? 170 : 210) + : (usingCompactCards ? 280 : 300), + child: TransformableListView.builder( + padding: const EdgeInsets.only(left: 20), + physics: const BouncingScrollPhysics( + decelerationRate: ScrollDecelerationRate.fast), + getTransformMatrix: getTransformMatrix, + scrollDirection: Axis.horizontal, + itemCount: carouselData!.length, + itemExtent: MediaQuery.of(context).size.width / + (usingSaikouCards ? 3.3 : 2.3), + itemBuilder: (context, index) { + final itemData = carouselData![index]; + final String posterUrl = itemData['poster'] ?? '??'; + final random = Random().nextInt(100000); + final tagg = '${itemData['animeId']}$tag$random'; + + const String proxyUrl = ''; + dynamic extraData = + 'Episode ${itemData['currentEpisode'].toString()}'; + '1'; + return Padding( + padding: const EdgeInsets.only(right: 10.0), + child: GestureDetector( + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => DetailsPage( + id: int.parse(itemData['anilistId']), + posterUrl: proxyUrl + posterUrl, + tag: tagg, + ), + ), + ); + }, + child: Column( + children: [ + Stack( + children: [ + Stack(children: [ + SizedBox( + height: usingSaikouCards ? 160 : 240, + child: Hero( + tag: tagg, + child: ClipRRect( + borderRadius: BorderRadius.circular(18), + child: CachedNetworkImage( + imageUrl: proxyUrl + posterUrl, + placeholder: (context, url) => + Shimmer.fromColors( + baseColor: Colors.grey[900]!, + highlightColor: Colors.grey[700]!, + child: Container( + color: Colors.grey[900], + width: double.infinity, + ), + ), + fit: BoxFit.cover, + width: double.infinity, + height: usingSaikouCards + ? (usingCompactCards ? 200 : 170) + : (usingCompactCards ? 280 : 250), + ), + ), + ), + ), + if (!usingCompactCards) + Positioned( + bottom: 0, + right: 0, + child: Container( + padding: const EdgeInsets.symmetric( + vertical: 6, horizontal: 12), + decoration: BoxDecoration( + color: Theme.of(context) + .colorScheme + .surfaceContainer, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(18), + bottomRight: Radius.circular(16))), + child: Text( + extraData, + style: TextStyle( + fontFamily: 'Poppins-SemiBold', + fontSize: 11, + color: Theme.of(context) + .colorScheme + .inverseSurface == + Theme.of(context) + .colorScheme + .onPrimaryFixedVariant + ? Colors.black + : Theme.of(context) + .colorScheme + .onPrimaryFixedVariant == + const Color(0xffe2e2e2) + ? Colors.black + : Colors.white), + ), + )), + if (usingCompactCards) + Positioned( + top: 0, + right: 0, + child: Container( + padding: const EdgeInsets.symmetric( + vertical: 4, horizontal: 12), + decoration: BoxDecoration( + color: Theme.of(context) + .colorScheme + .surfaceContainer, + borderRadius: const BorderRadius.only( + bottomLeft: Radius.circular(18), + topRight: Radius.circular(16))), + child: Text( + extraData, + style: TextStyle( + fontFamily: 'Poppins-Bold', + fontSize: 11, + color: Theme.of(context) + .colorScheme + .inverseSurface == + Theme.of(context) + .colorScheme + .onPrimaryFixedVariant + ? Colors.black + : Theme.of(context) + .colorScheme + .onPrimaryFixedVariant == + const Color(0xffe2e2e2) + ? Colors.black + : Colors.white), + ), + )), + ]), + if (usingCompactCards) + Positioned( + bottom: 0, + left: 0, + right: 0, + child: Container( + height: 60, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + gradient: LinearGradient( + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + colors: [ + Colors.black.withOpacity(0.8), + Colors.transparent, + ], + ), + ), + ), + ), + if (usingCompactCards) + Positioned( + bottom: 10, + left: 10, + right: 10, + child: Text( + itemData?['animeTitle'], + style: TextStyle( + color: Theme.of(context) + .colorScheme + .inverseSurface == + Theme.of(context) + .colorScheme + .onPrimaryFixedVariant + ? Colors.black + : Theme.of(context) + .colorScheme + .onPrimaryFixedVariant == + const Color(0xffe2e2e2) + ? Colors.black + : Colors.white, + fontSize: usingSaikouCards ? 10 : 13, + fontWeight: FontWeight.bold, + shadows: [ + Shadow( + blurRadius: 4, + color: Colors.black.withOpacity(0.7), + offset: const Offset(2, 2), + ), + ], + ), + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ) + else + Column( + children: [ + SizedBox( + height: usingSaikouCards ? 164 : 248, + width: MediaQuery.of(context).size.width / + (usingSaikouCards ? 3.3 : 2.3), + ), + Text( + itemData?['animeTitle'], + style: TextStyle( + color: Theme.of(context) + .colorScheme + .inverseSurface == + Theme.of(context) + .colorScheme + .onPrimaryFixedVariant + ? Colors.black + : Theme.of(context) + .colorScheme + .onPrimaryFixedVariant == + const Color(0xffe2e2e2) + ? Colors.black + : Colors.white, + fontSize: usingSaikouCards ? 10 : 13, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ], + ), + const SizedBox(height: 8), + ], + ), + ), + ); + }, + ), + ), + ], + ); + } +} diff --git a/lib/components/android/home/manga_homepage_carousel.dart b/lib/components/android/home/manga_homepage_carousel.dart new file mode 100644 index 00000000..951baa36 --- /dev/null +++ b/lib/components/android/home/manga_homepage_carousel.dart @@ -0,0 +1,312 @@ +import 'dart:math'; +import 'package:aurora/components/android/helper/scroll_helper.dart'; +import 'package:aurora/pages/Android/Manga/details_page.dart'; +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:hive/hive.dart'; +import 'package:shimmer/shimmer.dart'; +import 'package:transformable_list_view/transformable_list_view.dart'; + +class MangaHomepageCarousel extends StatelessWidget { + final List? carouselData; + final String? title; + final String? tag; + MangaHomepageCarousel({super.key, this.title, this.carouselData, this.tag}); + + final ScrollDirectionHelper _scrollDirectionHelper = ScrollDirectionHelper(); + @override + Widget build(BuildContext context) { + final bool usingCompactCards = + Hive.box('app-data').get('usingCompactCards', defaultValue: false); + final bool usingSaikouCards = + Hive.box('app-data').get('usingSaikouCards', defaultValue: true); + if (carouselData == null || carouselData!.isEmpty) { + return const SizedBox.shrink(); + } + + Matrix4 getTransformMatrix(TransformableListItem item) { + const maxScale = 1; + const minScale = 0.9; + final viewportWidth = item.constraints.viewportMainAxisExtent; + final itemLeftEdge = item.offset.dx; + final itemRightEdge = item.offset.dx + item.size.width; + + bool isScrollingRight = + _scrollDirectionHelper.isScrollingRight(item.offset); + + double visiblePortion; + if (isScrollingRight) { + visiblePortion = (viewportWidth - itemLeftEdge) / item.size.width; + } else { + visiblePortion = (itemRightEdge) / item.size.width; + } + + if ((isScrollingRight && itemLeftEdge < viewportWidth) || + (!isScrollingRight && itemRightEdge > 0)) { + const scaleRange = maxScale - minScale; + final scale = + minScale + (scaleRange * visiblePortion).clamp(0.0, scaleRange); + + return Matrix4.identity() + ..translate(item.size.width / 2, 0, 0) + ..scale(scale) + ..translate(-item.size.width / 2, 0, 0); + } + + return Matrix4.identity(); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20.0), + child: Text( + title ?? '??', + style: TextStyle( + fontSize: 16, + fontFamily: 'Poppins-SemiBold', + color: Theme.of(context).colorScheme.primary, + ), + ), + ), + const SizedBox(height: 10), + SizedBox( + height: usingSaikouCards + ? (usingCompactCards ? 180 : 210) + : (usingCompactCards ? 280 : 300), + child: TransformableListView.builder( + padding: const EdgeInsets.only(left: 20), + physics: const BouncingScrollPhysics( + decelerationRate: ScrollDecelerationRate.fast), + getTransformMatrix: getTransformMatrix, + scrollDirection: Axis.horizontal, + itemCount: carouselData!.length, + itemExtent: MediaQuery.of(context).size.width / + (usingSaikouCards ? 3.3 : 2.3), + itemBuilder: (context, index) { + final itemData = carouselData![index]; + final String posterUrl = itemData['poster'] ?? '??'; + final random = Random().nextInt(100000); + final tagg = '${itemData['id']}$tag$random'; + String? extraData = + itemData['currentChapter']!.toString().length > 13 + ? itemData['currentChapter']?.toString().substring(0, 13) + : itemData['currentChapter']?.toString() ?? '??'; + + return Padding( + padding: const EdgeInsets.only(right: 10.0), + child: GestureDetector( + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => MangaDetailsPage( + id: int.parse(itemData['anilistId']), + posterUrl: posterUrl, + tag: tag, + ))); + }, + child: Column( + children: [ + Stack( + children: [ + Stack(children: [ + SizedBox( + height: usingSaikouCards ? 160 : 240, + child: Hero( + tag: tagg, + child: ClipRRect( + borderRadius: BorderRadius.circular(18), + child: CachedNetworkImage( + imageUrl: posterUrl, + placeholder: (context, url) => + Shimmer.fromColors( + baseColor: Colors.grey[900]!, + highlightColor: Colors.grey[700]!, + child: Container( + color: Colors.grey[900], + width: double.infinity, + ), + ), + fit: BoxFit.cover, + width: double.infinity, + height: usingSaikouCards + ? (usingCompactCards ? 200 : 170) + : (usingCompactCards ? 280 : 250), + ), + ), + ), + ), + if (!usingCompactCards) + Positioned( + bottom: 0, + right: 0, + child: Container( + padding: const EdgeInsets.symmetric( + vertical: 4, horizontal: 8), + decoration: BoxDecoration( + color: Theme.of(context) + .colorScheme + .surfaceContainer, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(11), + bottomRight: Radius.circular(16))), + child: Text( + extraData!, + style: TextStyle( + fontFamily: 'Poppins-SemiBold', + fontSize: 11, + color: Theme.of(context) + .colorScheme + .inverseSurface == + Theme.of(context) + .colorScheme + .onPrimaryFixedVariant + ? Colors.black + : Theme.of(context) + .colorScheme + .onPrimaryFixedVariant == + const Color(0xffe2e2e2) + ? Colors.black + : Colors.white), + ), + )), + if (usingCompactCards) + Positioned( + top: 0, + right: 0, + child: Container( + padding: const EdgeInsets.symmetric( + vertical: 4, horizontal: 8), + decoration: BoxDecoration( + color: Theme.of(context) + .colorScheme + .surfaceContainer, + borderRadius: const BorderRadius.only( + bottomLeft: Radius.circular(12), + topRight: Radius.circular(16))), + child: Text( + extraData!, + style: TextStyle( + fontFamily: 'Poppins-SemiBold', + fontSize: 11, + color: Theme.of(context) + .colorScheme + .inverseSurface == + Theme.of(context) + .colorScheme + .onPrimaryFixedVariant + ? Colors.black + : Theme.of(context) + .colorScheme + .onPrimaryFixedVariant == + const Color(0xffe2e2e2) + ? Colors.black + : Colors.white), + ), + )), + ]), + if (usingCompactCards) + Positioned( + bottom: 0, + left: 0, + right: 0, + child: Container( + height: 60, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + gradient: LinearGradient( + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + colors: [ + Colors.black.withOpacity(0.8), + Colors.transparent, + ], + ), + ), + ), + ), + if (usingCompactCards) + Positioned( + bottom: 10, + left: 10, + right: 10, + child: Text( + itemData?['mangaTitle'], + style: TextStyle( + color: Theme.of(context) + .colorScheme + .inverseSurface == + Theme.of(context) + .colorScheme + .onPrimaryFixedVariant + ? Colors.black + : Theme.of(context) + .colorScheme + .onPrimaryFixedVariant == + const Color(0xffe2e2e2) + ? Colors.black + : Colors.white, + fontSize: usingSaikouCards ? 10 : 13, + fontFamily: 'Poppins-SemiBold', + shadows: [ + Shadow( + blurRadius: 4, + color: Colors.black.withOpacity(0.7), + offset: const Offset(2, 2), + ), + ], + ), + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ) + else + Column( + children: [ + SizedBox( + height: usingSaikouCards ? 164 : 248, + width: MediaQuery.of(context).size.width / + (usingSaikouCards ? 3.3 : 2.3), + ), + Text( + itemData?['mangaTitle'], + style: TextStyle( + color: Theme.of(context) + .colorScheme + .inverseSurface == + Theme.of(context) + .colorScheme + .onPrimaryFixedVariant + ? Colors.black + : Theme.of(context) + .colorScheme + .onPrimaryFixedVariant == + const Color(0xffe2e2e2) + ? Colors.black + : Colors.white, + fontSize: usingSaikouCards ? 10 : 13, + fontFamily: 'Poppins-SemiBold', + ), + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ], + ), + const SizedBox(height: 8), + ], + ), + ), + ); + }, + ), + ), + ], + ); + } +} diff --git a/lib/components/android/manga/chapter_ranges.dart b/lib/components/android/manga/chapter_ranges.dart new file mode 100644 index 00000000..01ca92d5 --- /dev/null +++ b/lib/components/android/manga/chapter_ranges.dart @@ -0,0 +1,86 @@ +import 'package:flutter/material.dart'; + +class ChapterRanges extends StatefulWidget { + final List> chapterRanges; + final Function(List) onRangeSelected; + + const ChapterRanges({ + super.key, + required this.chapterRanges, + required this.onRangeSelected, + }); + + @override + _ChapterRangesState createState() => _ChapterRangesState(); +} + +class _ChapterRangesState extends State { + List? activeRange; + int index = -1; + @override + Widget build(BuildContext context) { + return ListView( + scrollDirection: Axis.horizontal, + children: widget.chapterRanges.map((range) { + index++; + return Row( + children: [ + RangeButton( + range: range, + isActive: activeRange == range || index == 0, + onRangeSelected: (selectedRange) { + setState(() { + activeRange = selectedRange; + }); + widget.onRangeSelected(selectedRange); + }, + ), + const SizedBox(width: 10), + ], + ); + }).toList(), + ); + } +} + +class RangeButton extends StatelessWidget { + final List range; + final bool isActive; + final Function(List) onRangeSelected; + + const RangeButton({ + super.key, + required this.range, + required this.isActive, + required this.onRangeSelected, + }); + + @override + Widget build(BuildContext context) { + return ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: isActive + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.surfaceContainer, + shape: RoundedRectangleBorder( + side: BorderSide( + width: 1, + color: isActive + ? Theme.of(context).colorScheme.secondary + : Theme.of(context).colorScheme.primary, + ), + borderRadius: BorderRadius.circular(12), + ), + ), + onPressed: () => onRangeSelected(range), + child: Text( + '${range[0].toStringAsFixed(1)}-${range[1].toStringAsFixed(1)}', + style: TextStyle( + color: isActive + ? Theme.of(context).colorScheme.onPrimary + : Theme.of(context).colorScheme.onSurface, + ), + ), + ); + } +} diff --git a/lib/components/android/manga/chapters.dart b/lib/components/android/manga/chapters.dart new file mode 100644 index 00000000..464538c0 --- /dev/null +++ b/lib/components/android/manga/chapters.dart @@ -0,0 +1,132 @@ +import 'package:flutter/material.dart'; +import 'package:text_scroll/text_scroll.dart'; +import 'package:aurora/pages/Android/Manga/read_page.dart'; + +class ChapterList extends StatelessWidget { + final List chaptersData; // Ensure this is a list + final String? id; + final String? posterUrl; + final String anilistId; + final String currentSource; + final dynamic rawChapters; + final String description; + + const ChapterList({ + super.key, + required this.chaptersData, + required this.id, + required this.posterUrl, + required this.currentSource, + required this.anilistId, + required this.rawChapters, + required this.description, + }); + + @override + Widget build(BuildContext context) { + if (chaptersData.isEmpty) { + return const SizedBox( + height: 300, + child: Center( + child: CircularProgressIndicator(), + ), + ); + } + + return Column( + children: chaptersData.map((manga) { + return Container( + margin: const EdgeInsets.only(bottom: 20), + padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 15), + width: MediaQuery.of(context).size.width, + height: 70, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + color: Theme.of(context).colorScheme.surfaceContainerHighest, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox( + width: 140, + child: TextScroll( + manga['title'] ?? '?', + mode: TextScrollMode.endless, + velocity: const Velocity(pixelsPerSecond: Offset(30, 0)), + delayBefore: const Duration(milliseconds: 500), + pauseBetween: const Duration(milliseconds: 1000), + textAlign: TextAlign.start, + style: const TextStyle(fontSize: 16), + ), + ), + const SizedBox(height: 5), + Text( + manga['date']?.toString() ?? '', + style: TextStyle( + fontSize: 12, + color: Colors.grey[400], + ), + ), + ], + ), + Row( + children: [ + const Icon( + Icons.whatshot, + size: 16, + ), + const SizedBox(width: 5), + Text( + manga['views']?.toString() ?? '??', + style: const TextStyle( + fontSize: 14, + ), + ), + const SizedBox(width: 15), + ElevatedButton( + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ReadingPage( + id: manga['id'], + mangaId: id!, + posterUrl: posterUrl!, + currentSource: currentSource, + anilistId: anilistId, + chapterList: rawChapters, + description: description, + ), + ), + ); + }, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 20), + backgroundColor: + Theme.of(context).colorScheme.secondaryContainer, + elevation: 10, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: const Text( + 'Read', + style: TextStyle( + color: Colors.white, + fontSize: 14, + ), + ), + ), + ], + ), + ], + ), + ); + }).toList(), + ); + } +} diff --git a/lib/components/android/manga/toggle_bars.dart b/lib/components/android/manga/toggle_bars.dart new file mode 100644 index 00000000..608dfaf1 --- /dev/null +++ b/lib/components/android/manga/toggle_bars.dart @@ -0,0 +1,379 @@ +import 'package:flutter/material.dart'; +import 'package:iconly/iconly.dart'; + +typedef ButtonTapCallback = void Function(String? index); +typedef LayoutCallback = void Function(BuildContext context); + +class ToggleBar extends StatefulWidget { + final dynamic mangaData; + final Widget child; + final String? title; + final String? chapter; + final int? totalImages; + final ScrollController scrollController; + final PageController pageController; + final ButtonTapCallback handleChapter; + final LayoutCallback showSettings; + final LayoutCallback showChapters; + final String currentLayout; + final double? pageNumber; + + const ToggleBar( + {super.key, + required this.child, + required this.title, + required this.chapter, + required this.totalImages, + required this.scrollController, + required this.handleChapter, + required this.showSettings, + required this.showChapters, + required this.currentLayout, + required this.pageController, + required this.pageNumber, + this.mangaData}); + + @override + State createState() => _ToggleBarState(); +} + +class _ToggleBarState extends State { + bool _areBarsVisible = false; + double _scrollProgress = 0.0; + int _currentPage = 1; + bool _isSliderBeingUsed = false; + + void _toggleBarsVisibility() { + setState(() { + _areBarsVisible = !_areBarsVisible; + }); + } + + @override + void initState() { + super.initState(); + widget.scrollController.addListener(_updateScrollProgress); + } + + void _updateScrollProgress() { + if (widget.scrollController.hasClients && widget.totalImages! > 0) { + final maxScrollExtent = widget.scrollController.position.maxScrollExtent; + final currentScroll = widget.scrollController.position.pixels; + final progress = currentScroll / maxScrollExtent; + + setState(() { + _scrollProgress = progress.clamp(0.0, 1.0); + _currentPage = ((progress * (widget.totalImages! - 1)) + 1).round(); + + if (!_isSliderBeingUsed && _areBarsVisible) { + _areBarsVisible = false; + } + }); + } + } + + void _onProgressBarTap(double progress) { + if (widget.currentLayout == 'Webtoon') { + if (widget.scrollController.hasClients) { + final targetPage = (progress * (widget.totalImages! - 1)).round(); + + widget.scrollController.jumpTo( + targetPage * + (widget.scrollController.position.maxScrollExtent / + (widget.totalImages! - 1)), + ); + } + } else { + if (widget.pageController.hasClients) { + final targetPage = (progress * (widget.totalImages! - 1)).round(); + widget.pageController.jumpToPage(targetPage); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Stack( + children: [ + GestureDetector( + onTap: _toggleBarsVisibility, + child: widget.child, + ), + AnimatedPositioned( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + top: _areBarsVisible ? 0 : -120, + left: 0, + right: 0, + child: Container( + height: 90, + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Theme.of(context).colorScheme.surface.withOpacity(0.5), + Colors.transparent, + ], + stops: const [0.5, 1.0], + ), + // color: + // Theme.of(context).colorScheme.surface.withOpacity(0.8) + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + IconButton( + onPressed: () { + Navigator.pop(context); + }, + icon: Icon(IconlyBold.arrow_left, + color: Theme.of(context).colorScheme.inverseSurface == + Theme.of(context) + .colorScheme + .onPrimaryFixedVariant + ? Colors.black + : Theme.of(context) + .colorScheme + .onPrimaryFixedVariant == + const Color(0xffe2e2e2) + ? Colors.black + : Colors.white), + ), + const SizedBox(width: 10), + Column( + mainAxisAlignment: MainAxisAlignment.end, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: MediaQuery.of(context).size.width - 190, + child: Text( + widget.chapter!.length > 30 + ? "${widget.chapter!.substring(0, 30)}..." + : widget.chapter!, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 16, + color: Theme.of(context) + .colorScheme + .inverseSurface == + Theme.of(context) + .colorScheme + .onPrimaryFixedVariant + ? Colors.black + : Theme.of(context) + .colorScheme + .onPrimaryFixedVariant == + const Color(0xffe2e2e2) + ? Colors.black + : Colors.white)), + ), + const SizedBox(height: 3), + SizedBox( + width: MediaQuery.of(context).size.width - 190, + child: Text(widget.title!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 12, + color: Theme.of(context) + .colorScheme + .inverseSurface == + Theme.of(context) + .colorScheme + .onPrimaryFixedVariant + ? Colors.black + : Theme.of(context) + .colorScheme + .onPrimaryFixedVariant == + const Color(0xffe2e2e2) + ? Colors.black + : Colors.white70)), + ), + ], + ), + const Expanded(child: SizedBox.shrink()), + // IconButton( + // onPressed: () { + // widget.showChapters(context); + // }, + // icon: const Icon(Iconsax.bookmark5)), + IconButton( + onPressed: () { + widget.showSettings(context); + }, + icon: const Icon(IconlyBold.setting)) + ], + ), + ), + ), + AnimatedPositioned( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + bottom: _areBarsVisible ? 0 : -150, + left: 0, + right: 0, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 10), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + colors: [ + Colors.black.withOpacity(0.5), + Colors.transparent, + ], + stops: const [0.5, 1.0], + ), + ), + child: Container( + margin: const EdgeInsets.only(bottom: 30), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const SizedBox(width: 5), + Container( + decoration: BoxDecoration( + color: Theme.of(context) + .colorScheme + .surface + .withOpacity(0.80), + borderRadius: BorderRadius.circular(30)), + child: IconButton( + icon: Icon(Icons.skip_previous_rounded, + color: widget.mangaData?['prevChapterId'] == '' + ? Colors.grey + : Theme.of(context) + .colorScheme + .inverseSurface == + Theme.of(context) + .colorScheme + .onPrimaryFixedVariant + ? Colors.black + : Theme.of(context) + .colorScheme + .onPrimaryFixedVariant == + const Color(0xffe2e2e2) + ? Colors.black + : Colors.white, + size: 35), + onPressed: () { + widget.handleChapter('left'); + }, + ), + ), + const SizedBox(width: 5), + Expanded( + child: Container( + decoration: BoxDecoration( + color: Theme.of(context) + .colorScheme + .surface + .withOpacity(0.80), + borderRadius: BorderRadius.circular(20)), + child: Slider( + divisions: + widget.totalImages == 0 ? 10 : widget.totalImages, + value: widget.currentLayout == 'Webtoon' + ? _scrollProgress + : (widget.pageNumber! / widget.totalImages!) + .clamp(0, 1), + label: + (_scrollProgress * (widget.totalImages! - 1) + 1) + .round() + .toString(), + onChangeStart: (_) { + setState(() { + _isSliderBeingUsed = true; + }); + }, + onChanged: (value) { + setState(() { + _scrollProgress = value; + }); + _onProgressBarTap(value); + }, + onChangeEnd: (_) { + setState(() { + _isSliderBeingUsed = false; + }); + }, + activeColor: Theme.of(context).colorScheme.primary, + inactiveColor: Theme.of(context) + .colorScheme + .inverseSurface + .withOpacity(0.1), + ), + ), + ), + const SizedBox(width: 5), + Container( + decoration: BoxDecoration( + color: Theme.of(context) + .colorScheme + .surface + .withOpacity(0.80), + borderRadius: BorderRadius.circular(30)), + child: IconButton( + icon: Icon(Icons.skip_next_rounded, + size: 35, + color: widget.mangaData?['nextChapterId'] == '' + ? Colors.grey + : Theme.of(context) + .colorScheme + .inverseSurface == + Theme.of(context) + .colorScheme + .onPrimaryFixedVariant + ? Colors.black + : Theme.of(context) + .colorScheme + .onPrimaryFixedVariant == + const Color(0xffe2e2e2) + ? Colors.black + : Colors.white), + onPressed: () { + widget.handleChapter('right'); + }, + ), + ), + const SizedBox(width: 5), + ], + ), + ), + ), + ), + Positioned( + bottom: 0, + left: 0, + right: 0, + child: Center( + child: Text( + '${widget.currentLayout == 'Webtoon' ? _currentPage : widget.pageNumber?.floor()} / ${widget.totalImages}', + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.inverseSurface == + Theme.of(context).colorScheme.onPrimaryFixedVariant + ? Colors.black + : Theme.of(context).colorScheme.onPrimaryFixedVariant == + const Color(0xffe2e2e2) + ? Colors.black + : Colors.white, + fontFamily: 'Poppins-SemiBold'), + ), + ), + ), + ], + ), + ); + } + + @override + void dispose() { + widget.scrollController.removeListener(_updateScrollProgress); + super.dispose(); + } +}