516 lines
19 KiB
Dart
516 lines
19 KiB
Dart
import 'package:didvan/models/new_statistic/new_statistics_model.dart';
|
|
import 'package:didvan/views/home/new_statistic/new_statistics_state.dart';
|
|
import 'package:didvan/views/widgets/didvan/text.dart';
|
|
import 'package:didvan/views/widgets/home_app_bar.dart';
|
|
import 'package:didvan/views/widgets/mini_chart.dart';
|
|
import 'package:didvan/views/widgets/shimmer_placeholder.dart';
|
|
import 'package:didvan/views/widgets/state_handlers/state_handler.dart';
|
|
import 'package:cached_network_image/cached_network_image.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:persian_number_utility/persian_number_utility.dart';
|
|
import 'package:provider/provider.dart';
|
|
|
|
class NewStatistic extends StatefulWidget {
|
|
const NewStatistic({super.key});
|
|
|
|
@override
|
|
State<NewStatistic> createState() => _NewStatisticState();
|
|
}
|
|
|
|
class _NewStatisticState extends State<NewStatistic> {
|
|
int? _selectedCategory;
|
|
int _visibleLimit = 40;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
context.read<NewStatisticState>().init();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final state = context.watch<NewStatisticState>();
|
|
return Scaffold(
|
|
body: RefreshIndicator(
|
|
onRefresh: state.getStatistic,
|
|
child: CustomScrollView(
|
|
physics: const AlwaysScrollableScrollPhysics(),
|
|
slivers: [
|
|
const SliverToBoxAdapter(child: HomeAppBar(showSearchField: false)),
|
|
SliverPadding(
|
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
|
|
sliver: SliverToBoxAdapter(
|
|
child: StateHandler<NewStatisticState>(
|
|
state: state,
|
|
enableEmptyState: false,
|
|
onRetry: state.getStatistic,
|
|
placeholder: const _IndustryPulseLoading(),
|
|
builder: (context, value) {
|
|
if (value.errorMessage != null) {
|
|
return _ErrorView(
|
|
message: value.errorMessage!,
|
|
onRetry: value.getStatistic,
|
|
);
|
|
}
|
|
if (value.contents.isEmpty) {
|
|
return const _EmptyView();
|
|
}
|
|
final selected = value.contents
|
|
.any((item) => item.category == _selectedCategory)
|
|
? _selectedCategory
|
|
: value.contents.first.category;
|
|
final category = value.contents.firstWhere(
|
|
(item) => item.category == selected,
|
|
);
|
|
final visibleItems =
|
|
category.contents.take(_visibleLimit).toList();
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
_MarketHero(
|
|
categories: value.contents,
|
|
generatedAt: value.generatedAt,
|
|
),
|
|
const SizedBox(height: 20),
|
|
SizedBox(
|
|
height: 42,
|
|
child: ListView.separated(
|
|
scrollDirection: Axis.horizontal,
|
|
itemCount: value.contents.length,
|
|
separatorBuilder: (_, __) =>
|
|
const SizedBox(width: 8),
|
|
itemBuilder: (context, index) {
|
|
final item = value.contents[index];
|
|
final isSelected = item.category == selected;
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
return ChoiceChip(
|
|
label: DidvanText(
|
|
item.header,
|
|
color: isSelected
|
|
? colorScheme.onPrimary
|
|
: colorScheme.onSurface,
|
|
fontSize: 12,
|
|
fontWeight: isSelected
|
|
? FontWeight.w700
|
|
: FontWeight.w500,
|
|
),
|
|
selected: isSelected,
|
|
showCheckmark: false,
|
|
backgroundColor:
|
|
colorScheme.surfaceContainerHighest,
|
|
selectedColor: colorScheme.primary,
|
|
disabledColor:
|
|
colorScheme.surfaceContainerHighest,
|
|
side: BorderSide(
|
|
color: isSelected
|
|
? colorScheme.primary
|
|
: colorScheme.outline
|
|
.withValues(alpha: .45),
|
|
),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 10,
|
|
vertical: 8,
|
|
),
|
|
onSelected: (_) => setState(
|
|
() {
|
|
_selectedCategory = item.category;
|
|
_visibleLimit = 40;
|
|
},
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
const SizedBox(height: 20),
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
DidvanText(
|
|
category.header,
|
|
style: Theme.of(context)
|
|
.textTheme
|
|
.titleLarge
|
|
?.copyWith(fontWeight: FontWeight.w800),
|
|
),
|
|
DidvanText(
|
|
'${category.contents.length} شاخص'
|
|
.toPersianDigit(),
|
|
color: Theme.of(context)
|
|
.colorScheme
|
|
.onSurfaceVariant,
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final columns = constraints.maxWidth >= 700 ? 3 : 2;
|
|
return GridView.builder(
|
|
shrinkWrap: true,
|
|
physics: const NeverScrollableScrollPhysics(),
|
|
itemCount: visibleItems.length,
|
|
gridDelegate:
|
|
SliverGridDelegateWithFixedCrossAxisCount(
|
|
crossAxisCount: columns,
|
|
crossAxisSpacing: 10,
|
|
mainAxisSpacing: 10,
|
|
childAspectRatio: columns == 3 ? 1.35 : 1.05,
|
|
),
|
|
itemBuilder: (context, index) =>
|
|
_MarketCard(item: visibleItems[index]),
|
|
);
|
|
},
|
|
),
|
|
if (visibleItems.length < category.contents.length) ...[
|
|
const SizedBox(height: 16),
|
|
OutlinedButton.icon(
|
|
onPressed: () =>
|
|
setState(() => _visibleLimit += 40),
|
|
icon: const Icon(Icons.add_rounded),
|
|
label: const Text('نمایش موارد بیشتر'),
|
|
),
|
|
],
|
|
],
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _MarketHero extends StatelessWidget {
|
|
final List<CategoryList> categories;
|
|
final DateTime? generatedAt;
|
|
|
|
const _MarketHero({required this.categories, required this.generatedAt});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final all = categories.expand((item) => item.contents).toList();
|
|
final up = all.where((item) => item.data.dp > 0).length;
|
|
final down = all.where((item) => item.data.dp < 0).length;
|
|
|
|
return Container(
|
|
padding: const EdgeInsets.all(20),
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(
|
|
begin: Alignment.topRight,
|
|
end: Alignment.bottomLeft,
|
|
colors: [
|
|
theme.colorScheme.primary,
|
|
theme.colorScheme.primary.withValues(alpha: .72),
|
|
],
|
|
),
|
|
borderRadius: BorderRadius.circular(24),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Container(
|
|
padding: const EdgeInsets.all(10),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white.withValues(alpha: .16),
|
|
borderRadius: BorderRadius.circular(14),
|
|
),
|
|
child: const Icon(Icons.monitor_heart_rounded,
|
|
color: Colors.white),
|
|
),
|
|
const SizedBox(width: 12),
|
|
const Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
DidvanText('نبض صنعت',
|
|
color: Colors.white,
|
|
fontSize: 20,
|
|
fontWeight: FontWeight.w800),
|
|
DidvanText('تصویر زنده بازارهای اثرگذار',
|
|
color: Colors.white70, fontSize: 12),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 22),
|
|
Row(
|
|
children: [
|
|
_HeroMetric(
|
|
label: 'شاخصها', value: '${all.length}'.toPersianDigit()),
|
|
const SizedBox(width: 8),
|
|
_HeroMetric(
|
|
label: 'صعودی',
|
|
value: '$up'.toPersianDigit(),
|
|
color: Colors.greenAccent),
|
|
const SizedBox(width: 8),
|
|
_HeroMetric(
|
|
label: 'نزولی',
|
|
value: '$down'.toPersianDigit(),
|
|
color: Colors.orangeAccent),
|
|
],
|
|
),
|
|
if (generatedAt != null) ...[
|
|
const SizedBox(height: 14),
|
|
DidvanText(
|
|
'آخرین بروزرسانی: ${_time(generatedAt!.toLocal())}'
|
|
.toPersianDigit(),
|
|
color: Colors.white70,
|
|
fontSize: 11,
|
|
),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
static String _time(DateTime value) =>
|
|
'${value.year}/${value.month.toString().padLeft(2, '0')}/${value.day.toString().padLeft(2, '0')} ${value.hour.toString().padLeft(2, '0')}:${value.minute.toString().padLeft(2, '0')}';
|
|
}
|
|
|
|
class _HeroMetric extends StatelessWidget {
|
|
final String label;
|
|
final String value;
|
|
final Color color;
|
|
|
|
const _HeroMetric({
|
|
required this.label,
|
|
required this.value,
|
|
this.color = Colors.white,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) => Expanded(
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 9),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white.withValues(alpha: .12),
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Column(
|
|
children: [
|
|
DidvanText(value,
|
|
color: color, fontSize: 18, fontWeight: FontWeight.w800),
|
|
DidvanText(label, color: Colors.white70, fontSize: 11),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
class _MarketCard extends StatelessWidget {
|
|
final Content item;
|
|
|
|
const _MarketCard({required this.item});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final chartPrices = item.data.prices;
|
|
final chartChange = chartPrices.length > 1
|
|
? chartPrices.last - chartPrices.first
|
|
: item.data.dp.toDouble();
|
|
final isUp = chartChange > 0;
|
|
final isDown = chartChange < 0;
|
|
final chartTrend = isUp ? 'high' : (isDown ? 'low' : 'none');
|
|
final trendColor = isUp
|
|
? const Color(0xff19a974)
|
|
: isDown
|
|
? theme.colorScheme.error
|
|
: theme.colorScheme.onSurfaceVariant;
|
|
return Container(
|
|
padding: const EdgeInsets.all(14),
|
|
decoration: BoxDecoration(
|
|
color: theme.colorScheme.surface,
|
|
borderRadius: BorderRadius.circular(18),
|
|
border: Border.all(color: theme.dividerColor.withValues(alpha: .35)),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
_MarketLogo(item: item, color: trendColor),
|
|
const SizedBox(width: 9),
|
|
Expanded(
|
|
child: DidvanText(
|
|
item.title.toPersianDigit(),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
if (item.label.isNotEmpty)
|
|
Container(
|
|
padding:
|
|
const EdgeInsets.symmetric(horizontal: 6, vertical: 3),
|
|
decoration: BoxDecoration(
|
|
color: theme.colorScheme.primary.withValues(alpha: .08),
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: DidvanText(item.label.toPersianDigit(),
|
|
color: theme.colorScheme.primary, fontSize: 9),
|
|
),
|
|
],
|
|
),
|
|
const Spacer(),
|
|
if (item.data.prices.length > 1)
|
|
MiniChart(
|
|
label: item.label,
|
|
width: double.infinity,
|
|
height: 30,
|
|
lineColor: trendColor,
|
|
changePercent: item.data.dp.toDouble(),
|
|
trend: chartTrend,
|
|
prices: item.data.prices,
|
|
),
|
|
DidvanText(item.data.p.toPersianDigit(),
|
|
fontSize: 18, fontWeight: FontWeight.w800),
|
|
const SizedBox(height: 5),
|
|
Row(
|
|
children: [
|
|
Icon(
|
|
isUp
|
|
? Icons.arrow_upward_rounded
|
|
: isDown
|
|
? Icons.arrow_downward_rounded
|
|
: Icons.remove_rounded,
|
|
color: trendColor,
|
|
size: 15,
|
|
),
|
|
const SizedBox(width: 3),
|
|
DidvanText('${item.data.dp.abs()}٪'.toPersianDigit(),
|
|
color: trendColor, fontSize: 12, fontWeight: FontWeight.w700),
|
|
if (item.subtitle != null) ...[
|
|
const SizedBox(width: 6),
|
|
Expanded(
|
|
child: DidvanText(
|
|
item.subtitle!.toPersianDigit(),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
fontSize: 10,
|
|
textAlign: TextAlign.end,
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _MarketLogo extends StatelessWidget {
|
|
final Content item;
|
|
final Color color;
|
|
|
|
const _MarketLogo({required this.item, required this.color});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final background = Theme.of(context).colorScheme.surfaceContainerHighest;
|
|
final fallback = Icon(Icons.show_chart_rounded, color: color, size: 20);
|
|
return Container(
|
|
width: 36,
|
|
height: 36,
|
|
padding: const EdgeInsets.all(6),
|
|
decoration: BoxDecoration(
|
|
color: background,
|
|
shape: BoxShape.circle,
|
|
border: Border.all(color: color.withValues(alpha: .18)),
|
|
),
|
|
clipBehavior: Clip.antiAlias,
|
|
child: item.imageUrl == null || item.imageUrl!.isEmpty
|
|
? fallback
|
|
: CachedNetworkImage(
|
|
imageUrl: item.imageUrl!,
|
|
fit: BoxFit.contain,
|
|
placeholder: (_, __) => fallback,
|
|
errorWidget: (_, __, ___) => fallback,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ErrorView extends StatelessWidget {
|
|
final String message;
|
|
final Future<void> Function() onRetry;
|
|
|
|
const _ErrorView({required this.message, required this.onRetry});
|
|
|
|
@override
|
|
Widget build(BuildContext context) => Padding(
|
|
padding: const EdgeInsets.only(top: 80),
|
|
child: Column(
|
|
children: [
|
|
Icon(Icons.cloud_off_rounded,
|
|
size: 52, color: Theme.of(context).colorScheme.error),
|
|
const SizedBox(height: 14),
|
|
DidvanText(message, textAlign: TextAlign.center),
|
|
const SizedBox(height: 12),
|
|
FilledButton.icon(
|
|
onPressed: onRetry,
|
|
icon: const Icon(Icons.refresh_rounded),
|
|
label: const Text('تلاش دوباره'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
class _EmptyView extends StatelessWidget {
|
|
const _EmptyView();
|
|
|
|
@override
|
|
Widget build(BuildContext context) => const Padding(
|
|
padding: EdgeInsets.only(top: 80),
|
|
child: Column(
|
|
children: [
|
|
Icon(Icons.query_stats_rounded, size: 52),
|
|
SizedBox(height: 12),
|
|
DidvanText('در حال حاضر دادهای برای نمایش وجود ندارد.'),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
class _IndustryPulseLoading extends StatelessWidget {
|
|
const _IndustryPulseLoading();
|
|
|
|
@override
|
|
Widget build(BuildContext context) => const Column(
|
|
children: [
|
|
ShimmerPlaceholder(
|
|
height: 190, borderRadius: BorderRadius.all(Radius.circular(24))),
|
|
SizedBox(height: 20),
|
|
ShimmerPlaceholder(
|
|
height: 42, borderRadius: BorderRadius.all(Radius.circular(16))),
|
|
SizedBox(height: 20),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: ShimmerPlaceholder(
|
|
height: 170,
|
|
borderRadius: BorderRadius.all(Radius.circular(18)))),
|
|
SizedBox(width: 10),
|
|
Expanded(
|
|
child: ShimmerPlaceholder(
|
|
height: 170,
|
|
borderRadius: BorderRadius.all(Radius.circular(18)))),
|
|
],
|
|
),
|
|
],
|
|
);
|
|
}
|