diff --git a/lib/assets/icons/calendar.svg b/lib/assets/icons/calendar.svg new file mode 100644 index 0000000..332ce46 --- /dev/null +++ b/lib/assets/icons/calendar.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/lib/assets/icons/clock.svg b/lib/assets/icons/clock.svg new file mode 100644 index 0000000..baf3724 --- /dev/null +++ b/lib/assets/icons/clock.svg @@ -0,0 +1,4 @@ + + + + diff --git a/lib/assets/icons/play-circle.svg b/lib/assets/icons/play-circle.svg new file mode 100644 index 0000000..fb3569d --- /dev/null +++ b/lib/assets/icons/play-circle.svg @@ -0,0 +1,4 @@ + + + + diff --git a/lib/assets/icons/timer.svg b/lib/assets/icons/timer.svg new file mode 100644 index 0000000..fb89c0c --- /dev/null +++ b/lib/assets/icons/timer.svg @@ -0,0 +1,4 @@ + + + + diff --git a/lib/routes/route_generator.dart b/lib/routes/route_generator.dart index f8b2184..881f4f6 100644 --- a/lib/routes/route_generator.dart +++ b/lib/routes/route_generator.dart @@ -1,5 +1,3 @@ -// lib/routes/route_generator.dart - // ignore_for_file: avoid_print import 'package:didvan/models/ai/ai_chat_args.dart'; @@ -30,6 +28,7 @@ import 'package:didvan/views/home/infography/infography_screen.dart'; import 'package:didvan/views/home/infography/infography_screen_state.dart'; import 'package:didvan/views/home/main/main_page_state.dart'; import 'package:didvan/views/home/home_state.dart'; +import 'package:didvan/views/home/media/media_page.dart'; import 'package:didvan/views/home/new_statistic/new_statistics_state.dart'; import 'package:didvan/views/home/new_statistic/statistics_details/stat_cats_general_screen.dart'; import 'package:didvan/views/home/new_statistic/statistics_details/stat_cats_general_state.dart'; @@ -43,6 +42,7 @@ import 'package:didvan/views/news/news_details/news_details_state.dart'; import 'package:didvan/views/news/news_state.dart'; import 'package:didvan/views/notification_time/notification_time_state.dart'; import 'package:didvan/views/podcasts/podcasts.dart'; +import 'package:didvan/views/podcasts/podcasts_state.dart'; import 'package:didvan/views/podcasts/studio_details/studio_details_state.dart'; import 'package:didvan/views/profile/profile.dart'; import 'package:didvan/views/radar/radar.dart'; @@ -496,6 +496,14 @@ class RouteGenerator { return _errorRoute( 'Invalid arguments for ${settings.name}: Expected Map with stories and tappedIndex.'); + case Routes.media: + return MaterialPageRoute( + builder: (_) => ChangeNotifierProvider( + create: (context) => PodcastsState(), + child: const MediaPage(), + ), + ); + default: return _errorRoute(settings.name ?? 'Unknown route'); } diff --git a/lib/routes/routes.dart b/lib/routes/routes.dart index 872d5af..09376e3 100644 --- a/lib/routes/routes.dart +++ b/lib/routes/routes.dart @@ -41,4 +41,5 @@ class Routes { static const String newStatic = '/new-static'; static const String web = '/web'; static const String storyViewer = '/story-viewer'; + static const String media = '/media'; } \ No newline at end of file diff --git a/lib/views/home/main/main_page.dart b/lib/views/home/main/main_page.dart index 875a9ec..8cffada 100644 --- a/lib/views/home/main/main_page.dart +++ b/lib/views/home/main/main_page.dart @@ -65,48 +65,55 @@ class _MainPageState extends State { onRetry: () => context.read().init(), state: context.watch(), builder: (context, state) { - return ListView( - padding: const EdgeInsets.only(top: 0, bottom: 16), + return Column( children: [ const HomeAppBar( showBackButton: false, showSearchField: true, ), - if (state.stories.isNotEmpty) ...[ - const TextDivider(text: 'دیده‌بان') - .animate() - .fadeIn(delay: 400.ms, duration: 500.ms), - const _DidvanSignalsTitle() - .animate() - .fadeIn(delay: 500.ms, duration: 500.ms), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: StorySection(stories: state.stories), - ).animate().fadeIn(delay: 600.ms, duration: 500.ms), - ], - const SizedBox(height: 12), - const TextDivider(text: 'پیشخوان استراتژیک') - .animate() - .fadeIn(delay: 700.ms, duration: 500.ms), - const Padding( - padding: EdgeInsets.symmetric(horizontal: 16), - child: MainPageMainContent(), - ).animate().fadeIn(delay: 800.ms, duration: 500.ms), - if (state.content != null && state.content!.lists.isNotEmpty) ...[ - const _ExploreLatestTitle() - .animate() - .fadeIn(delay: 900.ms, duration: 500.ms), - _ExploreLatestSlider( - lists: state.content!.lists, - swotItems: state.swotItems, - ).animate().fadeIn(delay: 1000.ms, duration: 500.ms), - ], - const _IndustryPulseTitle() - .animate() - .fadeIn(delay: 1100.ms, duration: 500.ms), - const _IndustryPulseCards() - .animate() - .fadeIn(delay: 1200.ms, duration: 500.ms), + Expanded( + child: ListView( + padding: const EdgeInsets.only(top: 0, bottom: 16), + children: [ + if (state.stories.isNotEmpty) ...[ + const TextDivider(text: 'دیده‌بان') + .animate() + .fadeIn(delay: 400.ms, duration: 500.ms), + const _DidvanSignalsTitle() + .animate() + .fadeIn(delay: 500.ms, duration: 500.ms), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: StorySection(stories: state.stories), + ).animate().fadeIn(delay: 600.ms, duration: 500.ms), + ], + const SizedBox(height: 12), + const TextDivider(text: 'پیشخوان استراتژیک') + .animate() + .fadeIn(delay: 700.ms, duration: 500.ms), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 16), + child: MainPageMainContent(), + ).animate().fadeIn(delay: 800.ms, duration: 500.ms), + if (state.content != null && + state.content!.lists.isNotEmpty) ...[ + const _ExploreLatestTitle() + .animate() + .fadeIn(delay: 900.ms, duration: 500.ms), + _ExploreLatestSlider( + lists: state.content!.lists, + swotItems: state.swotItems, + ).animate().fadeIn(delay: 1000.ms, duration: 500.ms), + ], + const _IndustryPulseTitle() + .animate() + .fadeIn(delay: 1100.ms, duration: 500.ms), + const _IndustryPulseCards() + .animate() + .fadeIn(delay: 1200.ms, duration: 500.ms), + ], + ), + ), ], ); }, @@ -510,4 +517,4 @@ class _IndustryPulseCard extends StatelessWidget { void onMarkChanged(int id, bool value) { UserProvider.changeStatisticMark(id, value); } -} +} \ No newline at end of file diff --git a/lib/views/home/media/media_page.dart b/lib/views/home/media/media_page.dart index 86e03fd..8bf0377 100644 --- a/lib/views/home/media/media_page.dart +++ b/lib/views/home/media/media_page.dart @@ -1,3 +1,5 @@ +import 'package:didvan/views/home/media/podcast_tab_page.dart'; +import 'package:didvan/views/home/media/videocast_tab_page.dart'; import 'package:didvan/views/widgets/didvan/text.dart'; import 'package:didvan/views/home/main/widgets/banner.dart'; import 'package:didvan/views/widgets/home_app_bar.dart'; @@ -17,7 +19,7 @@ class _MediaPageState extends State { final List _categories = [ 'پادکست‌', - 'ویدیوکست', + 'ویدیوکست', 'فولادینفو', 'اینفوگرافی', ]; @@ -66,7 +68,8 @@ class _MediaPageState extends State { ), Container( width: double.infinity, - padding: const EdgeInsets.symmetric(horizontal: 7), // 10% فاصله از هر طرف برای 80% + padding: + const EdgeInsets.symmetric(horizontal: 7), child: Container( height: 2, color: const Color.fromRGBO(184, 184, 184, 1), @@ -81,44 +84,12 @@ class _MediaPageState extends State { }); }, children: [ - // محتوای تب پادکست‌ها - Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.podcasts, - size: 48, - color: Theme.of(context).colorScheme.primary, - ), - const SizedBox(height: 8), - DidvanText( - 'صفحه پادکست‌ها', - style: Theme.of(context).textTheme.titleSmall, - ), - ], - ), - ), - Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.video_library, - size: 48, - color: Theme.of(context).colorScheme.primary, - ), - const SizedBox(height: 8), - DidvanText( - 'صفحه ویدئوها', - style: Theme.of(context).textTheme.titleSmall, - ), - ], - ), - ), + const PodcastTabPage(), + const VideoCastTabPage(), const SingleChildScrollView( child: Padding( - padding: EdgeInsets.symmetric(horizontal: 16.0, vertical: 16.0), + padding: EdgeInsets.symmetric( + horizontal: 16.0, vertical: 16.0), child: MainPageBanner(isFirst: true), ), ), diff --git a/lib/views/home/media/podcast_tab_page.dart b/lib/views/home/media/podcast_tab_page.dart new file mode 100644 index 0000000..f8f5431 --- /dev/null +++ b/lib/views/home/media/podcast_tab_page.dart @@ -0,0 +1,66 @@ +import 'package:didvan/models/requests/studio.dart'; +import 'package:didvan/views/podcasts/podcasts_state.dart'; +import 'package:didvan/views/widgets/overview/podcast.dart'; +import 'package:didvan/views/widgets/state_handlers/empty_result.dart'; +import 'package:didvan/views/widgets/state_handlers/state_handler.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class PodcastTabPage extends StatefulWidget { + const PodcastTabPage({super.key}); + + @override + State createState() => _PodcastTabPageState(); +} + +class _PodcastTabPageState extends State { + @override + void initState() { + super.initState(); + Future.microtask(() { + context.read().init(true); + context.read().getStudios(page: 1); + }); + } + + @override + Widget build(BuildContext context) { + final state = context.watch(); + + return StateHandler( + state: state, + emptyState: EmptyResult( + onNewSearch: () {}, + ), + enableEmptyState: state.studios.isEmpty, + placeholder: PodcastOverview.placeholder, + builder: (context, state) { + return ListView.builder( + itemCount: state.studios.length, + itemBuilder: (context, index) { + final podcast = state.studios[index]; + return Padding( + padding: const EdgeInsets.only( + bottom: 8, + left: 16, + right: 16, + ), + child: PodcastOverview( + podcast: podcast, + onMarkChanged: state.changeMark, + studioRequestArgs: StudioRequestArgs( + page: state.page, + order: state.order, + search: state.search, + type: state.type, + asc: state.selectedSortTypeIndex == 1, + ), + ), + ); + }, + ); + }, + onRetry: () => context.read().getStudios(page: 1), + ); + } +} \ No newline at end of file diff --git a/lib/views/home/media/videocast_tab_page.dart b/lib/views/home/media/videocast_tab_page.dart new file mode 100644 index 0000000..988a8ec --- /dev/null +++ b/lib/views/home/media/videocast_tab_page.dart @@ -0,0 +1,99 @@ +import 'package:didvan/routes/routes.dart'; +import 'package:didvan/views/home/media/widgets/featured_video_card.dart'; +import 'package:didvan/views/home/media/widgets/videocast_grid_card.dart'; +import 'package:didvan/views/podcasts/podcasts_state.dart'; +import 'package:didvan/views/widgets/overview/podcast.dart'; +import 'package:didvan/views/widgets/state_handlers/empty_result.dart'; +import 'package:didvan/views/widgets/state_handlers/state_handler.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class VideoCastTabPage extends StatefulWidget { + const VideoCastTabPage({super.key}); + + @override + State createState() => _VideoCastTabPageState(); +} + +class _VideoCastTabPageState extends State { + @override + void initState() { + super.initState(); + Future.microtask(() { + context.read().init(false); + context.read().getStudios(page: 1); + }); + } + + @override + Widget build(BuildContext context) { + final state = context.watch(); + + return StateHandler( + state: state, + emptyState: EmptyResult( + onNewSearch: () {}, + ), + enableEmptyState: state.studios.isEmpty, + placeholder: PodcastOverview.placeholder, + builder: (context, state) { + if (state.studios.isEmpty) { + return const SizedBox.shrink(); + } + + return SingleChildScrollView( + child: Column( + children: [ + FeaturedVideoCard( + videocast: state.studios.first, + onTap: () { + Navigator.pushNamed( + context, + Routes.studioDetails, + arguments: { + 'id': state.studios.first.id, + 'type': state.studios.first.type, + }, + ); + }, + ), + + if (state.studios.length > 1) + Padding( + padding: const EdgeInsets.all(16), + child: GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + crossAxisSpacing: 12, + mainAxisSpacing: 12, + childAspectRatio: 0.75, + ), + itemCount: state.studios.length - 1, + itemBuilder: (context, index) { + final videocast = state.studios[index + 1]; + return VideocastGridCard( + videocast: videocast, + onTap: () { + Navigator.pushNamed( + context, + Routes.studioDetails, + arguments: { + 'id': videocast.id, + 'type': videocast.type, + }, + ); + }, + ); + }, + ), + ), + ], + ), + ); + }, + onRetry: () => context.read().getStudios(page: 1), + ); + } +} \ No newline at end of file diff --git a/lib/views/home/media/widgets/featured_video_card.dart b/lib/views/home/media/widgets/featured_video_card.dart new file mode 100644 index 0000000..d0cadf1 --- /dev/null +++ b/lib/views/home/media/widgets/featured_video_card.dart @@ -0,0 +1,189 @@ +import 'package:didvan/models/overview_data.dart'; +import 'package:didvan/views/widgets/didvan/text.dart'; +import 'package:didvan/views/widgets/skeleton_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:persian_number_utility/persian_number_utility.dart'; + +class FeaturedVideoCard extends StatelessWidget { + final OverviewData videocast; + final VoidCallback onTap; + + const FeaturedVideoCard({ + super.key, + required this.videocast, + required this.onTap, + }); + + String _formatDuration(int? duration) { + if (duration == null) return ''; + final minutes = duration ~/ 60; + final seconds = duration % 60; + return '”${seconds.toString().padLeft(2, '0')}:’${minutes.toString().padLeft(2, '0')}'; + } + + @override + Widget build(BuildContext context) { + return Container( + height: MediaQuery.of(context).size.height * 0.40, + margin: const EdgeInsets.all(16), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: Stack( + children: [ + Positioned.fill( + child: ColorFiltered( + colorFilter: const ColorFilter.matrix([ + 0.2126, 0.7152, 0.0722, 0, 0, + 0.2126, 0.7152, 0.0722, 0, 0, + 0.2126, 0.7152, 0.0722, 0, 0, + 0, 0, 0, 1, 0, + ]), + child: SkeletonImage( + imageUrl: videocast.image, + width: double.infinity, + height: double.infinity, + borderRadius: BorderRadius.circular(16), + ), + ), + ), + + Positioned( + bottom: 0, + left: 0, + right: 0, + child: Container( + height: 200, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + colors: [ + Colors.black.withOpacity(0.8), + Colors.black.withOpacity(0.4), + Colors.transparent, + ], + ), + ), + ), + ), + + Positioned( + bottom: 16, + left: 16, + right: 16, + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.end, + children: [ + IntrinsicWidth( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + DidvanText( + videocast.title, + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + color: const Color.fromARGB(255, 200, 224, 244), + fontWeight: FontWeight.bold, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 8), + Container( + height: 2, + color: const Color.fromARGB(255, 200, 224, 244), + ), + ], + ), + ), + + const SizedBox(height: 12), + + DidvanText( + videocast.description, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Colors.white, + height: 1.4, + ), + maxLines: 4, + overflow: TextOverflow.ellipsis, + ), + + const SizedBox(height: 5), + + Align( + alignment: AlignmentDirectional.centerEnd, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + SvgPicture.asset("lib/assets/icons/timer.svg"), + const SizedBox(width: 6), + DidvanText( + _formatDuration(videocast.duration).toPersianDigit(), + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Colors.white, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: onTap, + icon: SvgPicture.asset('lib/assets/icons/play-circle.svg'), + label: const DidvanText( + 'مشاهده ویدیو', + style: TextStyle(color: Colors.white), + ), + style: ElevatedButton.styleFrom( + backgroundColor: const Color.fromARGB(255, 178, 4, 54), + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ), + ], + ), + ), + const SizedBox(width: 16), + Padding( + padding: const EdgeInsets.only(bottom: 60.0), + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: SkeletonImage( + imageUrl: videocast.image, + width: 115, + height: 155, + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/views/home/media/widgets/videocast_grid_card.dart b/lib/views/home/media/widgets/videocast_grid_card.dart new file mode 100644 index 0000000..252d598 --- /dev/null +++ b/lib/views/home/media/widgets/videocast_grid_card.dart @@ -0,0 +1,132 @@ +import 'package:didvan/models/overview_data.dart'; +import 'package:didvan/views/widgets/didvan/text.dart'; +import 'package:didvan/views/widgets/skeleton_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:persian_number_utility/persian_number_utility.dart'; + +class VideocastGridCard extends StatelessWidget { + final OverviewData videocast; + final VoidCallback onTap; + + const VideocastGridCard({ + super.key, + required this.videocast, + required this.onTap, + }); + + String _formatDuration(int? duration) { + if (duration == null) return ''; + final minutes = duration ~/ 60; + final seconds = duration % 60; + return '”${seconds.toString().padLeft(2, '0')}:’${minutes.toString().padLeft(2, '0')}'; + } + + String _formatDate(String dateStr) { + try { + final date = DateTime.parse(dateStr); + return date.toPersianDateStr(); + } catch (e) { + return dateStr; + } + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + decoration: BoxDecoration( + color: const Color.fromRGBO(235, 235, 235, 1), + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Video cover + Padding( + padding: const EdgeInsets.all(6.0), + child: AspectRatio( + aspectRatio: 17 / 14, + child: ClipRRect( + borderRadius: BorderRadius.circular(20), + child: SkeletonImage( + imageUrl: videocast.image, + width: double.infinity, + height: double.infinity, + borderRadius: BorderRadius.circular(20), + ), + ), + ), + ), + + Expanded( + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 4, 12, 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + DidvanText( + videocast.title, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + color: Colors.black87, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Row( + children: [ + SvgPicture.asset('lib/assets/icons/calendar.svg'), + const SizedBox(width: 4), + DidvanText( + _formatDate(videocast.createdAt), + style: Theme.of(context) + .textTheme + .bodySmall + ?.copyWith( + color: const Color.fromARGB(255, 102, 102, 102), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + const SizedBox(height: 4), + Row( + children: [ + SvgPicture.asset( + 'lib/assets/icons/clock.svg', + color: const Color.fromARGB(255, 102, 102, 102), + ), + const SizedBox(width: 4), + DidvanText( + _formatDuration(videocast.duration).toPersianDigit(), + style: Theme.of(context) + .textTheme + .bodySmall + ?.copyWith( + color: const Color.fromARGB(255, 102, 102, 102), + ), + ), + ], + ), + ], + ), + ), + ), + ], + ), + ), + ); + } +}