didvan-app/lib/views/home/new_statistic/new_statistic.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)))),
],
),
],
);
}