diff --git a/lib/config/theme_data.dart b/lib/config/theme_data.dart index b1e6db7..5bbab96 100644 --- a/lib/config/theme_data.dart +++ b/lib/config/theme_data.dart @@ -192,6 +192,7 @@ extension DidvanColorScheme on ColorScheme { Color get overlay => brightness == Brightness.dark ? const Color(0xFF0F1011) : const Color(0xFF292929); + Color get yellow => const Color(0XFFEAA92A); // Error and success colors Color get errorBack => brightness == Brightness.dark diff --git a/lib/models/statistic_data/data.dart b/lib/models/statistic_data/data.dart new file mode 100644 index 0000000..0e46084 --- /dev/null +++ b/lib/models/statistic_data/data.dart @@ -0,0 +1,64 @@ +class Data { + final String p; + final String h; + final String l; + final String d; + final double dp; + final String dt; + final String t; + final String? tEn; + final String tG; + final String ts; + + Data({ + required this.p, + required this.h, + required this.l, + required this.d, + required this.dp, + required this.dt, + required this.t, + required this.tEn, + required this.tG, + required this.ts, + }); + + factory Data.fromJson(Map json) => Data( + p: json['p'], + h: json['h'], + l: json['l'], + d: json['d'], + dp: double.parse(json['dp'].toString()), + dt: json['dt'], + t: json['t'], + tEn: json['t_en'], + tG: json['t-g'], + ts: json['ts'], + ); + + factory Data.fromList(List list) => Data( + p: list[0], + h: list[1], + l: list[2], + d: list[3], + dp: double.parse(list[4].toString().replaceAll('-', '0')), + dt: list[5], + t: list[6], + tEn: list[7], + tG: '', + ts: '', + ); + + Map toJson() => { + 'p': p, + 'h': h, + 'l': l, + 'd': d, + 'dp': dp, + 'dt': dt, + 't': t, + 't_en': tEn, + 't-g': tG, + 'ts': ts, + }; +} diff --git a/lib/models/statistic_data/statistic_data.dart b/lib/models/statistic_data/statistic_data.dart new file mode 100644 index 0000000..5d720e7 --- /dev/null +++ b/lib/models/statistic_data/statistic_data.dart @@ -0,0 +1,29 @@ +import 'data.dart'; + +class StatisticData { + final int id; + final String label; + final String title; + final Data data; + + StatisticData({ + required this.id, + required this.label, + required this.title, + required this.data, + }); + + factory StatisticData.fromJson(Map json) => StatisticData( + id: json['id'], + label: json['label'], + title: json['title'], + data: Data.fromJson(json['data']), + ); + + Map toJson() => { + 'id': id, + 'label': label, + 'title': title, + 'data': data.toJson(), + }; +} diff --git a/lib/providers/user.dart b/lib/providers/user.dart index d87e0ce..f0341e8 100644 --- a/lib/providers/user.dart +++ b/lib/providers/user.dart @@ -16,6 +16,7 @@ class UserProvider extends CoreProvier { static final List _radarMarkQueue = []; static final List _newsMarkQueue = []; static final List _studioMarkQueue = []; + static final List _statisticMarkQueue = []; Future setAndGetToken({String? newToken}) async { if (newToken == null) { @@ -184,4 +185,20 @@ class UserProvider extends CoreProvier { _newsMarkQueue.removeWhere((element) => element.key == id); }); } + + static Future changeStatisticMark(int id, bool value) async { + _statisticMarkQueue.add(MapEntry(id, value)); + Future.delayed(const Duration(milliseconds: 500), () async { + final MapEntry? lastChange = + _statisticMarkQueue.lastWhereOrNull((item) => item.key == id); + if (lastChange == null) return; + final service = RequestService(RequestHelper.mark(id, 'statistic')); + if (lastChange.value) { + await service.post(); + } else { + await service.delete(); + } + _statisticMarkQueue.removeWhere((element) => element.key == id); + }); + } } diff --git a/lib/routes/route_generator.dart b/lib/routes/route_generator.dart index 3340685..47cf367 100644 --- a/lib/routes/route_generator.dart +++ b/lib/routes/route_generator.dart @@ -24,7 +24,9 @@ import 'package:didvan/views/home/settings/direct_list/direct_list_state.dart'; import 'package:didvan/views/home/settings/general_settings/settings.dart'; import 'package:didvan/views/home/settings/general_settings/settings_state.dart'; import 'package:didvan/views/home/settings/profile/profile.dart'; -import 'package:didvan/views/home/statistics/statistics_state.dart'; +import 'package:didvan/views/home/statistic/statistic_details/statistic_details.dart'; +import 'package:didvan/views/home/statistic/statistic_details/statistic_details_state.dart'; +import 'package:didvan/views/home/statistic/statistic_state.dart'; import 'package:didvan/views/home/studio/studio_details/studio_details.mobile.dart' if (dart.library.io) 'package:didvan/views/home/studio/studio_details/studio_details.mobile.dart' if (dart.library.html) 'package:didvan/views/home/studio/studio_details/studio_details.web.dart'; @@ -65,8 +67,8 @@ class RouteGenerator { ChangeNotifierProvider( create: (context) => StudioState(), ), - ChangeNotifierProvider( - create: (context) => StatisticsState(), + ChangeNotifierProvider( + create: (context) => StatisticState(), ), ], child: const Home(), @@ -111,6 +113,15 @@ class RouteGenerator { pageData: settings.arguments as Map, ), ); + case Routes.statisticDetails: + return _createRoute( + ChangeNotifierProvider( + create: (context) => StatisticDetailsState(), + child: StatisticDetails( + pageData: settings.arguments as Map, + ), + ), + ); case Routes.directList: return _createRoute( ChangeNotifierProvider( diff --git a/lib/routes/routes.dart b/lib/routes/routes.dart index fe94d78..23657ab 100644 --- a/lib/routes/routes.dart +++ b/lib/routes/routes.dart @@ -9,6 +9,7 @@ class Routes { static const String radarDetails = '/radar-details'; static const String newsDetails = '/news-details'; static const String studioDetails = '/studio-details'; + static const String statisticDetails = '/statistic-details'; static const String directList = '/direct-list'; static const String direct = '/direct'; static const String comments = '/comments'; diff --git a/lib/services/network/request_helper.dart b/lib/services/network/request_helper.dart index 525622e..6583f1d 100644 --- a/lib/services/network/request_helper.dart +++ b/lib/services/network/request_helper.dart @@ -8,6 +8,7 @@ class RequestHelper { static const String _baseRadarUrl = baseUrl + '/radar'; static const String _baseNewsUrl = baseUrl + '/news'; static const String _baseStudioUrl = baseUrl + '/studio'; + static const String _baseStatisticUrl = baseUrl + '/statistic'; static const String _baseDirectUrl = _baseUserUrl + '/direct'; static const String confirmUsername = _baseUserUrl + '/confirmUsername'; @@ -118,6 +119,21 @@ class RequestHelper { MapEntry('asc', args.asc), ]); + static String statisticOverviews(int? category) => + _baseStatisticUrl + + _urlConcatGenerator( + [MapEntry('category', category)], + ); + static String statisticDetails( + String label, + String period, + ) => + _baseStatisticUrl + + '/$label' + + _urlConcatGenerator([ + MapEntry('period', period), + ]); + static String mark(int id, String type) => baseUrl + '/$type/$id/mark'; static String tracking(int id, String type) => baseUrl + '/$type/$id/tracking'; diff --git a/lib/views/home/home.dart b/lib/views/home/home.dart index 914bb5e..aec1a56 100644 --- a/lib/views/home/home.dart +++ b/lib/views/home/home.dart @@ -3,7 +3,7 @@ import 'package:didvan/views/home/home_state.dart'; import 'package:didvan/views/home/news/news.dart'; import 'package:didvan/views/home/radar/radar.dart'; import 'package:didvan/views/home/settings/settings.dart'; -import 'package:didvan/views/home/statistics/statistics.dart'; +import 'package:didvan/views/home/statistic/statistic.dart'; import 'package:didvan/views/home/studio/studio.dart'; import 'package:didvan/views/widgets/didvan/bnb.dart'; import 'package:flutter/material.dart'; @@ -37,7 +37,7 @@ class _HomeState extends State with SingleTickerProviderStateMixin { controller: _tabController, children: const [ News(), - Statistics(), + Statistic(), Radar(), Studio(), Settings(), diff --git a/lib/views/home/news/news.dart b/lib/views/home/news/news.dart index c09916c..29cc5d8 100644 --- a/lib/views/home/news/news.dart +++ b/lib/views/home/news/news.dart @@ -37,7 +37,7 @@ class _NewsState extends State { onRetry: () => state.getNews(page: state.page), state: state, builder: (context, state) => ListView.builder( - cacheExtent: 1000, + cacheExtent: 1500, itemBuilder: (context, index) { if (index == 0) { return const LogoAppBar(); @@ -68,14 +68,21 @@ class _NewsState extends State { return NewsOverview.placeholder; } final news = state.news[index]; - return NewsOverview( - news: news, - onMarkChanged: state.onMarkChanged, - newsRequestArgs: NewsRequestArgs( - page: state.page, - endDate: state.endDate, - startDate: state.startDate, - search: state.search, + return Padding( + padding: const EdgeInsets.only( + left: 16, + right: 16, + bottom: 16, + ), + child: NewsOverview( + news: news, + onMarkChanged: state.onMarkChanged, + newsRequestArgs: NewsRequestArgs( + page: state.page, + endDate: state.endDate, + startDate: state.startDate, + search: state.search, + ), ), ); }, diff --git a/lib/views/home/radar/radar.dart b/lib/views/home/radar/radar.dart index b68a108..78ebc55 100644 --- a/lib/views/home/radar/radar.dart +++ b/lib/views/home/radar/radar.dart @@ -164,6 +164,7 @@ class _RadarState extends State { if (state.appState != AppState.failed) CategoriesRow1( topPadding: 300, + rightPadding: 124, onSelected: _onCategorySelected, categories: state.categories, isColapsed: @@ -180,11 +181,10 @@ class _RadarState extends State { !state.searching && !state.filtering) CategoriesList( - isRadar: true, categories: state.categories, isColapsed: state.isColapsed || state.searching || state.filtering, - onSelected: () => state.getRadars(page: 1), + onSelected: (_) => state.getRadars(page: 1), selectedCats: state.selectedCats, ), ], diff --git a/lib/views/home/statistics/statistics.dart b/lib/views/home/statistic/statistic.dart similarity index 61% rename from lib/views/home/statistics/statistics.dart rename to lib/views/home/statistic/statistic.dart index 565c41a..1651395 100644 --- a/lib/views/home/statistics/statistics.dart +++ b/lib/views/home/statistic/statistic.dart @@ -2,30 +2,29 @@ import 'dart:math'; import 'package:didvan/config/design_config.dart'; import 'package:didvan/config/theme_data.dart'; -import 'package:didvan/constants/assets.dart'; import 'package:didvan/models/category.dart'; import 'package:didvan/models/enums.dart'; -import 'package:didvan/models/requests/radar.dart'; -import 'package:didvan/views/home/statistics/statistics_state.dart'; +import 'package:didvan/models/statistic_data/statistic_data.dart'; +import 'package:didvan/views/home/statistic/statistic_state.dart'; +import 'package:didvan/views/home/statistic/widgets/statistic_overview.dart'; import 'package:didvan/views/home/widgets/categories_gird.dart'; import 'package:didvan/views/home/widgets/categories_list.dart'; import 'package:didvan/views/home/widgets/logo_app_bar.dart'; -import 'package:didvan/views/home/widgets/overview/radar.dart'; import 'package:didvan/views/widgets/animated_visibility.dart'; import 'package:didvan/views/widgets/didvan/text.dart'; -import 'package:didvan/views/widgets/state_handlers/empty_state.dart'; +import 'package:didvan/views/widgets/state_handlers/empty_list.dart'; import 'package:didvan/views/widgets/state_handlers/sliver_state_handler.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -class Statistics extends StatefulWidget { - const Statistics({Key? key}) : super(key: key); +class Statistic extends StatefulWidget { + const Statistic({Key? key}) : super(key: key); @override - State createState() => _RadarState(); + State createState() => _StatisticState(); } -class _RadarState extends State { +class _StatisticState extends State { final ScrollController _scrollController = ScrollController(); bool _isAnimating = false; @@ -35,7 +34,7 @@ class _RadarState extends State { _scrollController.addListener(() { _handleAnimations(); }); - final state = context.read(); + final state = context.read(); state.addListener(() { if (state.shouldColapse && mounted) { _handleAnimations(true); @@ -48,7 +47,7 @@ class _RadarState extends State { @override Widget build(BuildContext context) { - return Consumer( + return Consumer( builder: (context, state, child) => Stack( children: [ CustomScrollView( @@ -60,7 +59,7 @@ class _RadarState extends State { const SliverToBoxAdapter(child: LogoAppBar()), if (state.appState != AppState.failed) const SliverToBoxAdapter( - child: SizedBox(height: 156), + child: SizedBox(height: 180), ), if (state.appState != AppState.failed) SliverPadding( @@ -80,85 +79,89 @@ class _RadarState extends State { ), ), ), - SliverStateHandler( - onRetry: () => state.getStatistics(page: state.page), + SliverStateHandler( + onRetry: () => state.getStatistic(page: state.page), state: state, itemPadding: const EdgeInsets.only( bottom: 20, left: 16, right: 16, ), - enableEmptyState: state.statistics.isEmpty, - emptyState: Padding( - padding: const EdgeInsets.only(bottom: 120), - child: EmptyState( - asset: Assets.emptyResult, - title: 'موردی برای نمایش وجود ندارد.', - ), - ), - placeholder: RadarOverview.placeholder, + emptyState: const EmptyList(), + enableEmptyState: _itemCount(state) == 0, + placeholder: StatisticOverview.placeHolder, builder: (context, state, index) { - index += 2; - if (index % 15 == 0 && state.lastPage != state.page) { - state.getStatistics(page: state.page + 1); + bool isMarked = false; + StatisticData statistic; + if (index < state.markedStatistics.length) { + isMarked = true; + statistic = state.markedStatistics[index]; + } else { + statistic = + state.statistics[index - state.markedStatistics.length]; } - index -= 2; - if (index >= state.statistics.length) { - return RadarOverview.placeholder; - } - final radar = state.statistics[index]; - return RadarOverview( - radar: radar, + return StatisticOverview( + statistic: statistic, + isMarked: isMarked, onMarkChanged: state.changeMark, - onCommentsChanged: (id, count) => {}, - radarRequestArgs: RadarRequestArgs( - page: state.page, - categories: - List.from(state.selectedCats.map((cat) => cat.id)), - isSingleItem: false, - ), ); }, - childCount: state.statistics.length + - (state.lastPage == state.page ? 0 : 3), + childCount: _itemCount(state), ), - if (state.statistics.length == 1) - const SliverToBoxAdapter( - child: SizedBox(height: 320), + SliverToBoxAdapter( + child: SizedBox( + height: state.appState == AppState.busy + ? 300 + : _itemCount(state) == 0 + ? 150 + : max( + MediaQuery.of(context).size.height - + _itemCount(state) * 120, + 0), ), + ), ], ), if (state.appState != AppState.failed) CategoriesRow1( onSelected: _onCategorySelected, - categories: state.categories, + categories: List.from(state.categories)..removeAt(0), isColapsed: state.isColapsed, - topPadding: 120, + topPadding: 144, + rightPadding: 300, ), if (state.appState != AppState.failed) CategoriesList( - isRadar: false, categories: state.categories, isColapsed: state.isColapsed, - onSelected: () => state.getStatistics(page: 1), - selectedCats: state.selectedCats, + onSelected: (id) { + state.selectedCategoryId = id; + state.getStatistic(page: 1); + }, + selectedCats: state.selectedCategory == null + ? [] + : [state.selectedCategory!], ), ], ), ); } + int _itemCount(state) => + state.markedStatistics.length + + (state.selectedCategoryId == 1 ? 0 : state.statistics.length); + void _onCategorySelected(CategoryData category) { - final state = context.read(); - state.selectedCats.clear(); + final state = context.read(); + state.selectedCategoryId = 0; if (category.id != 0) { - state.selectedCats.add(category); + state.selectedCategoryId = category.id; } - state.getStatistics(page: 1); + state.getStatistic(page: 1); } void _handleAnimations([bool forceAnimate = false]) async { - final state = context.read(); + final state = context.read(); if (_isAnimating) return; final double position = _scrollController.offset; if (position > 5 && !state.isColapsed || forceAnimate) { @@ -166,14 +169,14 @@ class _RadarState extends State { _isAnimating = true; setState(() {}); await _scrollController.animateTo( - 200, + 228, duration: DesignConfig.mediumAnimationDuration, curve: Curves.easeIn, ); _isAnimating = false; setState(() {}); } else if (position < - min(_scrollController.position.maxScrollExtent, 200) && + min(_scrollController.position.maxScrollExtent, 228) && state.isColapsed) { state.isScrolled = false; _isAnimating = true; diff --git a/lib/views/home/statistic/statistic_details/statistic_details.dart b/lib/views/home/statistic/statistic_details/statistic_details.dart new file mode 100644 index 0000000..bc7fdcf --- /dev/null +++ b/lib/views/home/statistic/statistic_details/statistic_details.dart @@ -0,0 +1,298 @@ +import 'package:didvan/config/theme_data.dart'; +import 'package:didvan/constants/app_icons.dart'; +import 'package:didvan/models/enums.dart'; +import 'package:didvan/models/view/app_bar_data.dart'; +import 'package:didvan/views/home/statistic/statistic_details/statistic_details_state.dart'; +import 'package:didvan/views/home/widgets/categories_list.dart'; +import 'package:didvan/views/home/widgets/overview/multitype.dart'; +import 'package:didvan/views/home/widgets/tag_item.dart'; +import 'package:didvan/views/widgets/didvan/card.dart'; +import 'package:didvan/views/widgets/didvan/divider.dart'; +import 'package:didvan/views/widgets/didvan/icon_button.dart'; +import 'package:didvan/views/widgets/didvan/scaffold.dart'; +import 'package:didvan/views/widgets/didvan/text.dart'; +import 'package:didvan/views/widgets/state_handlers/state_handler.dart'; +import 'package:fl_chart/fl_chart.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_spinkit/flutter_spinkit.dart'; +import 'package:provider/provider.dart'; +import 'package:intl/intl.dart' as intl; + +class StatisticDetails extends StatefulWidget { + final Map pageData; + const StatisticDetails({Key? key, required this.pageData}) : super(key: key); + + @override + State createState() => _StatisticDetailsState(); +} + +class _StatisticDetailsState extends State { + @override + void initState() { + final state = context.read(); + state.label = widget.pageData['label']; + state.marked = widget.pageData['marked']; + state.currentDateRangeId = 0; + Future.delayed(Duration.zero, state.getStatisticDetails); + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, state, child) => DidvanScaffold( + padding: EdgeInsets.zero, + appBarData: AppBarData( + title: widget.pageData['title'], + hasBack: true, + subtitle: 'رادار قیمت‌ها', + trailing: DidvanIconButton( + icon: state.marked ? Icons.star : Icons.star_border, + color: state.marked + ? Theme.of(context).colorScheme.yellow + : Theme.of(context).colorScheme.focusedBorder, + size: 32, + onPressed: () { + state.marked = !state.marked; + state.update(); + widget.pageData['onMarkChanged'](state.marked); + }, + ), + ), + children: [ + StateHandler( + topPadding: MediaQuery.of(context).size.height / 3, + builder: (context, state) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 20), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 16), + child: DidvanText('نمودار تغییرات'), + ), + const SizedBox(height: 20), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: SizedBox( + width: double.infinity, + height: 120, + child: state.chartState == AppState.busy + ? SpinKitThreeBounce( + color: Theme.of(context).colorScheme.primary, + size: 24, + ) + : LineChart( + LineChartData( + lineTouchData: LineTouchData( + touchTooltipData: LineTouchTooltipData( + tooltipBgColor: + Theme.of(context).colorScheme.navigation, + getTooltipItems: (data) => [ + LineTooltipItem( + state.datas[data.first.spotIndex].tEn! + + '\n' + + intl.NumberFormat("###,000", "en_US") + .format( + data.first.bar + .spots[data.first.spotIndex].y, + ), + Theme.of(context) + .textTheme + .caption! + .copyWith( + color: Colors.white, + ), + ), + ], + ), + ), + minX: 0, + maxX: state.datas.length.toDouble() - 1, + maxY: state.maxValue * 1.001, + minY: state.minValue, + gridData: FlGridData(show: false), + borderData: FlBorderData(show: false), + titlesData: FlTitlesData(show: false), + lineBarsData: [ + LineChartBarData( + spots: [ + for (var i = 0; i < state.datas.length; i++) + FlSpot( + i.toDouble(), + _stringToDouble(state.datas[i].p), + ) + ], + barWidth: 2, + dotData: FlDotData( + getDotPainter: (p0, p1, p2, p3) => + FlDotCirclePainter( + color: Colors.transparent, + strokeWidth: 1, + strokeColor: + Theme.of(context).colorScheme.success, + ), + ), + color: Theme.of(context).colorScheme.success, + belowBarData: BarAreaData( + show: true, + gradient: LinearGradient( + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + tileMode: TileMode.decal, + colors: [ + Theme.of(context) + .colorScheme + .background, + const Color(0XFFF5B763) + .withOpacity(0.2), + ], + ), + ), + ), + ], + ), + ), + ), + ), + const SizedBox(height: 20), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 16), + child: DidvanText('بازه نمایش:'), + ), + const SizedBox(height: 20), + CategoriesList( + isColapsed: false, + isAppBar: false, + selectedCats: [state.currentDateRange], + categories: state.dateRanges, + onSelected: (id) { + state.currentDateRangeId = id; + state.getStatisticDetails(); + }, + ), + const SizedBox(height: 16), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: DidvanCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildDataItem('قیمت لحظه‌ای', state.data!.p), + const DidvanDivider(verticalPadding: 8), + _buildDataItem('بالاترین قیمت روز', state.data!.h), + const SizedBox(height: 8), + _buildDataItem('پایین‌ترین قیمت روز', state.data!.l), + const SizedBox(height: 8), + _buildDataItem( + 'درصد تغییر نسبت به دیروز', + '${state.data!.dp}%', + icon: _diffIcon(state), + color: _diffColor(state), + ), + const SizedBox(height: 8), + _buildDataItem( + 'میزان تغییر نسبت به دیروز', + state.data!.d, + icon: _diffIcon(state), + color: _diffColor(state), + ), + const SizedBox(height: 16), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (var i = 0; i < state.tags.length; i++) + TagItem( + tag: state.tags[i], + onMarkChanged: (_, __) {}, + type: 'statistic', + ), + ], + ), + ], + ), + ), + ), + const SizedBox(height: 16), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: DidvanCard( + child: Column( + children: [ + if (state.relatedContents.isEmpty) + for (var i = 0; i < 3; i++) ...[ + MultitypeOverview.placeholder, + if (i != 2) const SizedBox(height: 16) + ], + for (var i = 0; + i < state.relatedContents.length; + i++) ...[ + MultitypeOverview( + item: state.relatedContents[i], + onMarkChanged: (id, value) {}, + ), + if (i != state.relatedContents.length - 1) + const SizedBox(height: 16) + ] + ], + ), + ), + ), + const SizedBox(height: 16), + ], + ), + onRetry: state.getStatisticDetails, + state: state, + ) + ], + ), + ); + } + + double _stringToDouble(String value) => + double.parse(value.replaceAll(',', '')); + + Color? _diffColor(StatisticDetailsState state) { + if (state.data!.dp == 0) { + return null; + } + if (state.data!.dt == 'low') { + return Theme.of(context).colorScheme.success; + } else { + return Theme.of(context).colorScheme.error; + } + } + + IconData? _diffIcon(StatisticDetailsState state) { + if (state.data!.dp == 0) { + return null; + } + if (state.data!.dt == 'low') { + return DidvanIcons.angle_up_regular; + } else { + return DidvanIcons.angle_down_regular; + } + } + + Widget _buildDataItem( + String title, + String value, { + IconData? icon, + bool isBold = false, + Color? color, + }) { + return Row( + children: [ + DidvanText( + title, + style: isBold + ? Theme.of(context).textTheme.bodyText1 + : Theme.of(context).textTheme.bodyText2, + ), + const Spacer(), + if (icon != null) Icon(icon, color: color), + DidvanText(value, color: color), + ], + ); + } +} diff --git a/lib/views/home/statistic/statistic_details/statistic_details_state.dart b/lib/views/home/statistic/statistic_details/statistic_details_state.dart new file mode 100644 index 0000000..9d22e04 --- /dev/null +++ b/lib/views/home/statistic/statistic_details/statistic_details_state.dart @@ -0,0 +1,168 @@ +import 'package:didvan/models/category.dart'; +import 'package:didvan/models/enums.dart'; +import 'package:didvan/models/overview_data.dart'; +import 'package:didvan/models/statistic_data/data.dart'; +import 'package:didvan/models/tag.dart'; +import 'package:didvan/providers/core.dart'; +import 'package:didvan/services/network/request.dart'; +import 'package:didvan/services/network/request_helper.dart'; +import 'package:collection/collection.dart'; + +class StatisticDetailsState extends CoreProvier { + late bool marked; + late String label; + String? startDate; + String? endDate; + int currentDateRangeId = 0; + final List datas = []; + final List relatedContents = []; + final List tags = []; + Data? data; + double maxValue = 0; + double? minValue; + + AppState chartState = AppState.idle; + + final dateRanges = [ + CategoryData( + id: 0, + label: 'هفتگی', + asset: 'weekly', + ), + CategoryData( + id: 1, + label: 'ماهانه', + asset: 'monthly', + ), + CategoryData( + id: 2, + label: 'شش ماهه', + asset: 'semiyearly', + ), + CategoryData( + id: 3, + label: 'سالانه', + asset: 'yearly', + ), + ]; + + CategoryData get currentDateRange => dateRanges.firstWhere( + (element) => element.id == currentDateRangeId, + ); + + Future getStatisticDetails() async { + if (data == null) { + final result = await getStatisticCurrentDetails(); + if (!result) { + appState = AppState.failed; + return; + } + } + minValue = null; + maxValue = 0; + if (datas.isEmpty) { + appState = AppState.busy; + } else { + chartState = AppState.busy; + notifyListeners(); + } + datas.clear(); + final service = RequestService( + RequestHelper.statisticDetails( + label, + dateRanges[currentDateRangeId].asset!, + ), + ); + await service.httpGet(); + if (service.isSuccess) { + final result = service.result['data']; + tags.clear(); + for (var i = 0; i < service.result['tags'].length; i++) { + tags.add(Tag.fromJson(service.result['tags'][i])); + } + for (var i = 0; i < result.length; i++) { + datas.add(Data.fromList(result[i])); + final highest = _stringToDouble(datas.last.h); + final lowest = _stringToDouble(datas.last.l); + if (highest > maxValue) { + maxValue = highest; + } + if (lowest < (minValue ?? _stringToDouble(datas.last.p))) { + minValue = lowest; + } + } + if (currentDateRangeId != 0 && currentDateRangeId != 1) { + final grouped = + datas.groupListsBy((element) => element.tEn!.split('/')[1]); + datas.clear(); + grouped.forEach((key, value) { + datas.add( + Data( + p: _average(value), + h: maxValue.toString(), + l: minValue.toString(), + d: '', + dp: 0, + dt: '', + t: '', + tEn: value.first.tEn!.substring(0, 7), + tG: '', + ts: '', + ), + ); + }); + } + getRelatedContents(); + datas.replaceRange(0, datas.length, datas.reversed); + chartState = AppState.idle; + appState = AppState.idle; + return; + } + if (datas.isEmpty) { + appState = AppState.failed; + } else { + chartState = AppState.failed; + notifyListeners(); + } + } + + Future getStatisticCurrentDetails() async { + final service = RequestService( + RequestHelper.statisticDetails( + label, + 'current', + ), + ); + await service.httpGet(); + if (service.isSuccess) { + data = Data.fromJson(service.result['data']); + } + return service.isSuccess; + } + + String _average(List inputs) { + double sum = 0; + for (var i = 0; i < inputs.length; i++) { + sum += _stringToDouble(inputs[i].p); + } + return (sum / inputs.length).toString(); + } + + double _stringToDouble(String value) => + double.parse(value.replaceAll(',', '')); + + Future getRelatedContents() async { + if (relatedContents.isNotEmpty) return; + final service = RequestService(RequestHelper.tag( + ids: tags.map((tag) => tag.id).toList(), + )); + await service.httpGet(); + if (service.isSuccess) { + final relateds = service.result['contents']; + for (var i = 0; i < relateds.length; i++) { + relatedContents.add(OverviewData.fromJson(relateds[i])); + } + notifyListeners(); + } + } +} diff --git a/lib/views/home/statistic/statistic_state.dart b/lib/views/home/statistic/statistic_state.dart new file mode 100644 index 0000000..2cbcaca --- /dev/null +++ b/lib/views/home/statistic/statistic_state.dart @@ -0,0 +1,115 @@ +import 'package:didvan/constants/assets.dart'; +import 'package:collection/collection.dart'; +import 'package:didvan/models/category.dart'; +import 'package:didvan/models/enums.dart'; +import 'package:didvan/models/statistic_data/statistic_data.dart'; +import 'package:didvan/providers/core.dart'; +import 'package:didvan/providers/user.dart'; +import 'package:didvan/services/network/request.dart'; +import 'package:didvan/services/network/request_helper.dart'; + +class StatisticState extends CoreProvier { + int page = 1; + bool isScrolled = false; + bool shouldColapse = false; + int selectedCategoryId = -1; + List categories = []; + final List statistics = []; + final List markedStatistics = []; + + bool get isColapsed => (isCategorySelected && isScrolled) || isScrolled; + + CategoryData? get selectedCategory => categories.firstWhereOrNull( + (element) => element.id == selectedCategoryId, + ); + + bool get isCategorySelected => selectedCategoryId != 0; + + void resetFilters(bool isInit) { + selectedCategoryId = 0; + isScrolled = false; + if (!isInit) { + getStatistic(page: 1); + } + } + + Future getStatistic({ + required int page, + }) async { + this.page = page; + if (this.page == page) { + statistics.clear(); + } + if (page == 1) { + appState = AppState.busy; + } + final RequestService service = RequestService( + RequestHelper.statisticOverviews( + selectedCategoryId == 0 || selectedCategoryId == 1 + ? null + : selectedCategoryId - 1, + ), + ); + await service.httpGet(); + if (service.isSuccess) { + final others = service.result['others']; + for (var i = 0; i < others.length; i++) { + statistics.add(StatisticData.fromJson(others[i])); + } + final marked = service.result['marked']; + for (var i = 0; i < marked.length; i++) { + statistics.add(StatisticData.fromJson(marked[i])); + } + if (isColapsed || isCategorySelected) { + shouldColapse = true; + } + appState = AppState.idle; + return; + } + + appState = AppState.failed; + } + + Future changeMark(int id, bool value) async { + final item = statistics.firstWhereOrNull((element) => element.id == id) ?? + markedStatistics.firstWhere((element) => element.id == id); + if (value) { + markedStatistics.add(item); + statistics.remove(item); + } else { + markedStatistics.remove(item); + statistics.add(item); + } + UserProvider.changeStatisticMark(id, value); + notifyListeners(); + } + + void init() { + resetFilters(true); + Future.delayed(Duration.zero, () { + getStatistic(page: 1); + }); + categories = [ + CategoryData( + id: 1, + label: 'منتخب', + asset: Assets.economicCategoryIcon, + ), + CategoryData( + id: 2, + label: 'اقتصاد کلان', + asset: Assets.economicCategoryIcon, + ), + CategoryData( + id: 3, + label: 'صنعت فولاد', + asset: Assets.politicalCategoryIcon, + ), + CategoryData( + id: 4, + label: 'بازار سرمایه', + asset: Assets.techCategoryIcon, + ), + ]; + } +} diff --git a/lib/views/home/statistic/widgets/statistic_overview.dart b/lib/views/home/statistic/widgets/statistic_overview.dart new file mode 100644 index 0000000..52906e5 --- /dev/null +++ b/lib/views/home/statistic/widgets/statistic_overview.dart @@ -0,0 +1,135 @@ +import 'package:didvan/config/theme_data.dart'; +import 'package:didvan/constants/app_icons.dart'; +import 'package:didvan/models/statistic_data/statistic_data.dart'; +import 'package:didvan/routes/routes.dart'; +import 'package:didvan/views/widgets/didvan/card.dart'; +import 'package:didvan/views/widgets/didvan/text.dart'; +import 'package:didvan/views/widgets/shimmer_placeholder.dart'; +import 'package:flutter/material.dart'; + +class StatisticOverview extends StatelessWidget { + final StatisticData statistic; + final bool isMarked; + final void Function(int id, bool value) onMarkChanged; + const StatisticOverview({ + Key? key, + required this.statistic, + required this.isMarked, + required this.onMarkChanged, + }) : super(key: key); + + Color _diffColor(context) => statistic.data.dt == 'low' + ? Theme.of(context).colorScheme.success + : Theme.of(context).colorScheme.error; + + bool get _hasDiff => statistic.data.d != '0'; + + @override + Widget build(BuildContext context) { + return DidvanCard( + onTap: () => + Navigator.of(context).pushNamed(Routes.statisticDetails, arguments: { + 'onMarkChanged': (value) => onMarkChanged(statistic.id, value), + 'label': statistic.label, + 'title': statistic.title, + 'marked': isMarked, + }), + child: Column( + children: [ + Row( + children: [ + if (isMarked) + Icon( + Icons.star, + color: Theme.of(context).colorScheme.yellow, + size: 18, + ), + DidvanText( + statistic.title, + style: Theme.of(context).textTheme.bodyText1, + ), + const Spacer(), + if (_hasDiff) + DidvanText( + '(${statistic.data.d})', + color: _diffColor(context), + ), + if (_hasDiff) const SizedBox(width: 8), + DidvanText( + statistic.data.p, + style: Theme.of(context).textTheme.bodyText1, + ) + ], + ), + const SizedBox(height: 8), + Row( + children: [ + Icon( + Icons.trending_down, + size: 18, + color: Theme.of(context).colorScheme.hint, + ), + DidvanText( + statistic.data.l, + style: Theme.of(context).textTheme.caption, + color: Theme.of(context).colorScheme.hint, + ), + const SizedBox(width: 8), + Icon( + Icons.trending_up, + size: 18, + color: Theme.of(context).colorScheme.hint, + ), + DidvanText( + statistic.data.h, + style: Theme.of(context).textTheme.caption, + color: Theme.of(context).colorScheme.hint, + ), + const Spacer(), + if (_hasDiff) + Icon( + statistic.data.dt == 'low' + ? DidvanIcons.angle_up_regular + : DidvanIcons.angle_down_regular, + size: 18, + color: _diffColor(context), + ), + if (_hasDiff) const SizedBox(width: 4), + if (_hasDiff) + DidvanText( + statistic.data.dp.toString() + '%', + style: Theme.of(context).textTheme.caption, + color: _diffColor(context), + ), + ], + ), + ], + ), + ); + } + + static Widget get placeHolder => DidvanCard( + child: Column( + children: [ + const SizedBox(height: 4), + Row( + children: const [ + ShimmerPlaceholder(width: 80, height: 16), + Spacer(), + ShimmerPlaceholder(width: 50, height: 14), + SizedBox(width: 8), + ShimmerPlaceholder(width: 50, height: 16), + ], + ), + const SizedBox(height: 16), + Row( + children: const [ + ShimmerPlaceholder(width: 150, height: 12), + Spacer(), + ShimmerPlaceholder(width: 80, height: 12), + ], + ), + ], + ), + ); +} diff --git a/lib/views/home/statistics/statistics_state.dart b/lib/views/home/statistics/statistics_state.dart deleted file mode 100644 index 6ebcaf7..0000000 --- a/lib/views/home/statistics/statistics_state.dart +++ /dev/null @@ -1,96 +0,0 @@ -import 'package:didvan/constants/assets.dart'; -import 'package:didvan/models/category.dart'; -import 'package:didvan/models/enums.dart'; -import 'package:didvan/models/overview_data.dart'; -import 'package:didvan/models/requests/radar.dart'; -import 'package:didvan/providers/core.dart'; -import 'package:didvan/services/network/request.dart'; -import 'package:didvan/services/network/request_helper.dart'; - -class StatisticsState extends CoreProvier { - int page = 1; - int lastPage = 1; - bool isScrolled = false; - bool shouldColapse = false; - final List selectedCats = []; - List categories = []; - final List statistics = []; - - bool get isColapsed => (isCategorySelected && isScrolled) || isScrolled; - - bool get isCategorySelected => selectedCats.length == 1; - - void resetFilters(bool isInit) { - selectedCats.clear(); - isScrolled = false; - if (!isInit) { - getStatistics(page: 1); - } - } - - Future getStatistics({ - required int page, - }) async { - this.page = page; - if (this.page == page) { - statistics.clear(); - } - if (page == 1) { - appState = AppState.busy; - } - final RequestService service = RequestService( - RequestHelper.radarOverviews( - args: RadarRequestArgs( - page: page, - categories: selectedCats.map((e) => e.id).toList(), - ), - ), - ); - await service.httpGet(); - if (service.isSuccess) { - lastPage = service.result['lastPage']; - final radarsList = service.result['radars']; - for (var i = 0; i < radarsList.length; i++) { - statistics.add(OverviewData.fromJson(radarsList[i])); - } - if (isColapsed || isCategorySelected) { - shouldColapse = true; - } - appState = AppState.idle; - return; - } - - appState = AppState.failed; - } - - Future changeMark(int id, bool value, bool shouldUpdate) async { - statistics.firstWhere((element) => element.id == id).marked = value; - if (shouldUpdate) { - notifyListeners(); - } - } - - void init() { - resetFilters(true); - Future.delayed(Duration.zero, () { - getStatistics(page: 1); - }); - categories = [ - CategoryData( - id: 1, - label: 'اقتصاد کلان', - asset: Assets.economicCategoryIcon, - ), - CategoryData( - id: 2, - label: 'صنعت فولاد', - asset: Assets.politicalCategoryIcon, - ), - CategoryData( - id: 3, - label: 'بازار سرمایه', - asset: Assets.techCategoryIcon, - ), - ]; - } -} diff --git a/lib/views/home/widgets/categories_gird.dart b/lib/views/home/widgets/categories_gird.dart index f63c745..564ad03 100644 --- a/lib/views/home/widgets/categories_gird.dart +++ b/lib/views/home/widgets/categories_gird.dart @@ -7,6 +7,7 @@ class CategoriesRow1 extends StatelessWidget { final List categories; final bool isColapsed; final double topPadding; + final double rightPadding; final void Function(CategoryData data) onSelected; const CategoriesRow1({ Key? key, @@ -14,6 +15,7 @@ class CategoriesRow1 extends StatelessWidget { required this.isColapsed, required this.onSelected, required this.topPadding, + required this.rightPadding, }) : super(key: key); @override @@ -23,8 +25,8 @@ class CategoriesRow1 extends StatelessWidget { curve: Curves.easeIn, duration: DesignConfig.mediumAnimationDuration, top: isColapsed ? -60 : topPadding + d.padding.top, - left: isColapsed ? -80 : 0, - right: isColapsed ? 124 : 0, + left: isColapsed ? -rightPadding : 0, + right: isColapsed ? rightPadding : 0, child: Row( children: categories .sublist(0, 3) diff --git a/lib/views/home/widgets/categories_list.dart b/lib/views/home/widgets/categories_list.dart index 5322119..5a718be 100644 --- a/lib/views/home/widgets/categories_list.dart +++ b/lib/views/home/widgets/categories_list.dart @@ -7,17 +7,17 @@ import 'package:flutter/material.dart'; class CategoriesList extends StatefulWidget { final bool isColapsed; + final bool isAppBar; final List selectedCats; final List categories; - final VoidCallback onSelected; - final bool isRadar; + final void Function(int id) onSelected; const CategoriesList({ Key? key, required this.isColapsed, required this.selectedCats, required this.categories, required this.onSelected, - required this.isRadar, + this.isAppBar = true, }) : super(key: key); @override @@ -46,54 +46,60 @@ class _CategoriesListState extends State { @override Widget build(BuildContext context) { final MediaQueryData d = MediaQuery.of(context); - return Positioned( - top: 0, - left: 0, - right: 0, - child: AnimatedCrossFade( - crossFadeState: widget.isColapsed - ? CrossFadeState.showSecond - : CrossFadeState.showFirst, - duration: DesignConfig.mediumAnimationDuration, - reverseDuration: DesignConfig.lowAnimationDuration, - sizeCurve: Curves.easeIn, - firstChild: const SizedBox(), - secondChild: Container( - height: 60 + d.padding.top, - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - boxShadow: DesignConfig.defaultShadow, - ), - child: AnimatedVisibility( - isVisible: widget.isColapsed, - duration: DesignConfig.mediumAnimationDuration, - child: SingleChildScrollView( - controller: _scrollController, - // physics: const BouncingScrollPhysics(), - scrollDirection: Axis.horizontal, - padding: EdgeInsets.only( - top: d.padding.top + 12, - bottom: 12, - right: 12, - ), - child: Row( - children: [ - _itemBuilder( - CategoryData( - label: widget.isRadar ? 'همه' : 'منتخب', - id: 0, - ), - context, - ), - for (var i = 0; i < widget.categories.length; i++) - _itemBuilder(widget.categories[i], context), - ], + final child = SingleChildScrollView( + controller: _scrollController, + scrollDirection: Axis.horizontal, + padding: widget.isAppBar + ? EdgeInsets.only( + top: d.padding.top + 12, + bottom: 12, + ) + : null, + child: Row( + children: [ + const SizedBox(width: 12), + if (widget.isAppBar) + _itemBuilder( + CategoryData( + label: 'همه', + id: 0, ), + context, + ), + for (var i = 0; i < widget.categories.length; i++) + _itemBuilder(widget.categories[i], context), + ], + ), + ); + if (widget.isAppBar) { + return Positioned( + top: 0, + left: 0, + right: 0, + child: AnimatedCrossFade( + crossFadeState: widget.isColapsed + ? CrossFadeState.showSecond + : CrossFadeState.showFirst, + duration: DesignConfig.mediumAnimationDuration, + reverseDuration: DesignConfig.lowAnimationDuration, + sizeCurve: Curves.easeIn, + firstChild: const SizedBox(), + secondChild: Container( + height: 60 + d.padding.top, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + boxShadow: DesignConfig.defaultShadow, + ), + child: AnimatedVisibility( + isVisible: widget.isColapsed, + duration: DesignConfig.mediumAnimationDuration, + child: child, ), ), ), - ), - ); + ); + } + return child; } Widget _itemBuilder(CategoryData category, BuildContext context) { @@ -108,7 +114,7 @@ class _CategoriesListState extends State { duration: DesignConfig.lowAnimationDuration, curve: Curves.easeIn, ); - widget.onSelected(); + widget.onSelected(category.id); }, child: Container( margin: const EdgeInsets.only(left: 12), @@ -127,7 +133,7 @@ class _CategoriesListState extends State { color: widget.selectedCats.length == 1 && widget.selectedCats.contains(category) || category.id == 0 && widget.selectedCats.isEmpty - ? Theme.of(context).colorScheme.focused + ? Theme.of(context).colorScheme.splash : null, border: Border.all( color: Theme.of(context).colorScheme.focusedBorder, diff --git a/lib/views/widgets/state_handlers/state_handler.dart b/lib/views/widgets/state_handlers/state_handler.dart index 0addd30..c54c651 100644 --- a/lib/views/widgets/state_handlers/state_handler.dart +++ b/lib/views/widgets/state_handlers/state_handler.dart @@ -38,10 +38,13 @@ class StateHandler extends StatelessWidget { case AppState.idle: return builder(context, state); case AppState.busy: - return placeholder ?? - SpinKitSpinningLines( - color: Theme.of(context).colorScheme.primary, - ); + return Padding( + padding: EdgeInsets.only(top: topPadding), + child: placeholder ?? + SpinKitSpinningLines( + color: Theme.of(context).colorScheme.primary, + ), + ); case AppState.failed: return Center(child: EmptyConnection(onRetry: onRetry)); default: diff --git a/pubspec.lock b/pubspec.lock index f08bc28..737d1d4 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -141,6 +141,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.0.5" + equatable: + dependency: transitive + description: + name: equatable + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.3" expandable_bottom_sheet: dependency: "direct main" description: @@ -211,6 +218,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.2.10" + fl_chart: + dependency: "direct main" + description: + name: fl_chart + url: "https://pub.dartlang.org" + source: hosted + version: "0.50.1" flutter: dependency: "direct main" description: flutter diff --git a/pubspec.yaml b/pubspec.yaml index dfc7dfa..adebac7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -66,6 +66,7 @@ dependencies: permission_handler: ^9.2.0 better_player: ^0.0.81 assets_audio_player: ^3.0.4+1 + fl_chart: ^0.50.1 dev_dependencies: