444 lines
17 KiB
Dart
444 lines
17 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(
|
|
bannerUrl: value.bannerUrl,
|
|
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('نمایش موارد بیشتر'),
|
|
),
|
|
],
|
|
],
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Top of the «نبض صنعت» tab: a banner image managed from the panel
|
|
/// (`/panel/market-symbols`) plus the last-updated timestamp. When no banner
|
|
/// has been uploaded the image area is simply omitted.
|
|
class _MarketHero extends StatelessWidget {
|
|
final String? bannerUrl;
|
|
final DateTime? generatedAt;
|
|
|
|
const _MarketHero({required this.bannerUrl, required this.generatedAt});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final hasBanner = bannerUrl != null && bannerUrl!.isNotEmpty;
|
|
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
if (hasBanner)
|
|
ClipRRect(
|
|
borderRadius: BorderRadius.circular(24),
|
|
child: AspectRatio(
|
|
aspectRatio: 16 / 9,
|
|
child: CachedNetworkImage(
|
|
imageUrl: bannerUrl!,
|
|
fit: BoxFit.cover,
|
|
placeholder: (_, __) => const ShimmerPlaceholder(
|
|
width: double.infinity,
|
|
height: double.infinity,
|
|
),
|
|
errorWidget: (_, __, ___) => const SizedBox.shrink(),
|
|
),
|
|
),
|
|
),
|
|
if (generatedAt != null) ...[
|
|
SizedBox(height: hasBanner ? 10 : 0),
|
|
DidvanText(
|
|
'آخرین بروزرسانی: ${_time(generatedAt!.toLocal())}'
|
|
.toPersianDigit(),
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
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 _MarketCard extends StatelessWidget {
|
|
final Content item;
|
|
|
|
const _MarketCard({required this.item});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
// The API percentage is the canonical change direction. A sparkline may
|
|
// cover a slightly different time window and must not contradict it.
|
|
final changePercent = item.data.dp.toDouble();
|
|
final isUp = changePercent > 0;
|
|
final isDown = changePercent < 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.formattedAbsDp}٪'.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)))),
|
|
],
|
|
),
|
|
],
|
|
);
|
|
}
|