indicators
This commit is contained in:
parent
820d0e4982
commit
b927666c12
@ -580,12 +580,21 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
|
|||||||
final firstResponse =
|
final firstResponse =
|
||||||
responses.isNotEmpty ? responses.first : <String, dynamic>{};
|
responses.isNotEmpty ? responses.first : <String, dynamic>{};
|
||||||
final secondResponse = responses.length > 1 ? responses[1] : null;
|
final secondResponse = responses.length > 1 ? responses[1] : null;
|
||||||
|
final trendText = _textValue(secondResponse?['value']);
|
||||||
return _IndicatorItem(
|
return _IndicatorItem(
|
||||||
title: _textValue(entry['chart_heading']),
|
title: _textValue(entry['chart_heading']),
|
||||||
value: _textValue(firstResponse['value']),
|
value: _textValue(firstResponse['value']),
|
||||||
period: _textValue(firstResponse['display_value']),
|
period: _textValue(firstResponse['display_value']),
|
||||||
trendText: _textValue(secondResponse?['value']),
|
trendText: trendText,
|
||||||
trendPeriod: _textValue(secondResponse?['display_value']),
|
trendPeriod: _textValue(secondResponse?['display_value']),
|
||||||
|
icon: _indicatorItemIcon(
|
||||||
|
topicName,
|
||||||
|
subTopicName,
|
||||||
|
_textValue(entry['chart_heading']),
|
||||||
|
_textValue(entry['kpi']),
|
||||||
|
),
|
||||||
|
sparklineValues: _sparklineFromResponses(responses),
|
||||||
|
isTrendUp: _isPositiveTrend(trendText),
|
||||||
);
|
);
|
||||||
}).toList();
|
}).toList();
|
||||||
|
|
||||||
@ -616,6 +625,104 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
|
|||||||
return double.tryParse(cleaned);
|
return double.tryParse(cleaned);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool _isPositiveTrend(String trendText) {
|
||||||
|
if (trendText.isEmpty) return true;
|
||||||
|
return !trendText.trim().startsWith('-');
|
||||||
|
}
|
||||||
|
|
||||||
|
List<double> _sparklineFromResponses(List<Map<String, dynamic>> responses) {
|
||||||
|
final values = <double>[];
|
||||||
|
for (final response in responses) {
|
||||||
|
final parsed = _parseNumericTrend(_textValue(response['value']));
|
||||||
|
if (parsed != null) {
|
||||||
|
values.add(parsed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (values.length < 2) return const [];
|
||||||
|
|
||||||
|
final minValue = values.reduce(math.min);
|
||||||
|
final maxValue = values.reduce(math.max);
|
||||||
|
if (maxValue == minValue) {
|
||||||
|
return List<double>.filled(values.length, 0.5);
|
||||||
|
}
|
||||||
|
return values
|
||||||
|
.map((value) => (value - minValue) / (maxValue - minValue))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
IconData _indicatorItemIcon(
|
||||||
|
String topicName,
|
||||||
|
String subTopicName,
|
||||||
|
String heading,
|
||||||
|
String kpi,
|
||||||
|
) {
|
||||||
|
final normalizedHeading = _normalizeKey(heading);
|
||||||
|
final normalizedKpi = _normalizeKey(kpi);
|
||||||
|
|
||||||
|
if (normalizedHeading.contains('male') ||
|
||||||
|
normalizedKpi.contains('male')) {
|
||||||
|
return Icons.person_outline;
|
||||||
|
}
|
||||||
|
if (normalizedHeading.contains('female') ||
|
||||||
|
normalizedKpi.contains('female')) {
|
||||||
|
return Icons.person_outline;
|
||||||
|
}
|
||||||
|
if (normalizedHeading.contains('enrollment') ||
|
||||||
|
normalizedHeading.contains('student') ||
|
||||||
|
normalizedKpi.contains('student')) {
|
||||||
|
return Icons.school_outlined;
|
||||||
|
}
|
||||||
|
if (normalizedHeading.contains('teacher') ||
|
||||||
|
normalizedKpi.contains('teacher')) {
|
||||||
|
return Icons.menu_book_outlined;
|
||||||
|
}
|
||||||
|
if (normalizedHeading.contains('hospital') ||
|
||||||
|
normalizedKpi.contains('hospital')) {
|
||||||
|
return Icons.local_hospital_outlined;
|
||||||
|
}
|
||||||
|
if (normalizedHeading.contains('bed') ||
|
||||||
|
normalizedKpi.contains('bed')) {
|
||||||
|
return Icons.hotel_outlined;
|
||||||
|
}
|
||||||
|
if (normalizedHeading.contains('health') ||
|
||||||
|
normalizedKpi.contains('health')) {
|
||||||
|
return Icons.medical_services_outlined;
|
||||||
|
}
|
||||||
|
if (normalizedHeading.contains('government')) {
|
||||||
|
return Icons.account_balance_outlined;
|
||||||
|
}
|
||||||
|
if (normalizedHeading.contains('private')) {
|
||||||
|
return Icons.business_outlined;
|
||||||
|
}
|
||||||
|
if (normalizedHeading.contains('growth') ||
|
||||||
|
normalizedHeading.contains('rate')) {
|
||||||
|
return Icons.trending_up_rounded;
|
||||||
|
}
|
||||||
|
if (normalizedHeading.contains('gdp')) {
|
||||||
|
return Icons.show_chart_rounded;
|
||||||
|
}
|
||||||
|
if (normalizedHeading.contains('import') ||
|
||||||
|
normalizedHeading.contains('export') ||
|
||||||
|
normalizedHeading.contains('trade')) {
|
||||||
|
return Icons.swap_horiz_rounded;
|
||||||
|
}
|
||||||
|
if (normalizedHeading.contains('temperature') ||
|
||||||
|
normalizedHeading.contains('climate')) {
|
||||||
|
return Icons.thermostat_outlined;
|
||||||
|
}
|
||||||
|
if (normalizedHeading.contains('electric') ||
|
||||||
|
normalizedHeading.contains('consumption') ||
|
||||||
|
normalizedHeading.contains('generation')) {
|
||||||
|
return Icons.bolt_outlined;
|
||||||
|
}
|
||||||
|
if (normalizedHeading.contains('crop') ||
|
||||||
|
normalizedHeading.contains('land')) {
|
||||||
|
return Icons.grass_outlined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return _sectionTileIcon(topicName, subTopicName);
|
||||||
|
}
|
||||||
|
|
||||||
List<_HomeSectionGroup> _buildDynamicTopicGroups(
|
List<_HomeSectionGroup> _buildDynamicTopicGroups(
|
||||||
Map<String, dynamic>? topic) {
|
Map<String, dynamic>? topic) {
|
||||||
final topicName = _textValue(topic?['main_topic']);
|
final topicName = _textValue(topic?['main_topic']);
|
||||||
@ -1132,6 +1239,9 @@ class _IndicatorItem {
|
|||||||
required this.period,
|
required this.period,
|
||||||
this.trendText = '',
|
this.trendText = '',
|
||||||
this.trendPeriod = '',
|
this.trendPeriod = '',
|
||||||
|
this.icon = Icons.insights_outlined,
|
||||||
|
this.sparklineValues = const [],
|
||||||
|
this.isTrendUp = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String title;
|
final String title;
|
||||||
@ -1139,6 +1249,9 @@ class _IndicatorItem {
|
|||||||
final String period;
|
final String period;
|
||||||
final String trendText;
|
final String trendText;
|
||||||
final String trendPeriod;
|
final String trendPeriod;
|
||||||
|
final IconData icon;
|
||||||
|
final List<double> sparklineValues;
|
||||||
|
final bool isTrendUp;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Hero card ─────────────────────────────────────────────────────────────────
|
// ── Hero card ─────────────────────────────────────────────────────────────────
|
||||||
@ -1210,8 +1323,8 @@ class _HeroCard extends ConsumerWidget {
|
|||||||
Flexible(
|
Flexible(
|
||||||
child: Text(
|
child: Text(
|
||||||
isArabic
|
isArabic
|
||||||
? 'تحديث · ١٥ مايو ٢٠٢٦'
|
? 'تحديث · ٠١ مارس ٢٠٢٦'
|
||||||
: 'Updated · 15 May 2026',
|
: 'Updated · 01 Mar 2026',
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
@ -2264,7 +2377,11 @@ class _IndicatorsSheet extends StatelessWidget {
|
|||||||
const Divider(height: 1, indent: 20, color: _kPearl),
|
const Divider(height: 1, indent: 20, color: _kPearl),
|
||||||
itemBuilder: (_, index) {
|
itemBuilder: (_, index) {
|
||||||
final indicator = indicators[index];
|
final indicator = indicators[index];
|
||||||
return _IndicatorRow(item: indicator, accentColor: iconColor);
|
return _IndicatorRow(
|
||||||
|
item: indicator,
|
||||||
|
accentColor: iconColor,
|
||||||
|
accentBg: iconBg,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -2276,17 +2393,36 @@ class _IndicatorsSheet extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _IndicatorRow extends StatelessWidget {
|
class _IndicatorRow extends StatelessWidget {
|
||||||
const _IndicatorRow({required this.item, required this.accentColor});
|
const _IndicatorRow({
|
||||||
|
required this.item,
|
||||||
|
required this.accentColor,
|
||||||
|
required this.accentBg,
|
||||||
|
});
|
||||||
|
|
||||||
final _IndicatorItem item;
|
final _IndicatorItem item;
|
||||||
final Color accentColor;
|
final Color accentColor;
|
||||||
|
final Color accentBg;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final up = item.isTrendUp;
|
||||||
|
final prevPeriod = item.period.isNotEmpty ? item.period : '—';
|
||||||
|
final vsLabel = item.trendPeriod.isNotEmpty ? item.trendPeriod : prevPeriod;
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: accentBg,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Icon(item.icon, size: 18, color: accentColor),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@ -2299,13 +2435,13 @@ class _IndicatorRow extends StatelessWidget {
|
|||||||
color: _kSlate900,
|
color: _kSlate900,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
if (item.period.isNotEmpty)
|
if (item.period.isNotEmpty)
|
||||||
Text(
|
Text(
|
||||||
item.period,
|
item.period,
|
||||||
style: const TextStyle(fontSize: 11, color: _kSlate400),
|
style:
|
||||||
|
const TextStyle(fontSize: 11, color: _kSlate400),
|
||||||
),
|
),
|
||||||
if (item.trendText.isNotEmpty) ...[
|
if (item.trendText.isNotEmpty) ...[
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
@ -2313,19 +2449,44 @@ class _IndicatorRow extends StatelessWidget {
|
|||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: 6, vertical: 1),
|
horizontal: 6, vertical: 1),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.successBg,
|
color: up ? AppColors.successBg : AppColors.errorBg,
|
||||||
borderRadius: BorderRadius.circular(999),
|
borderRadius: BorderRadius.circular(999),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Row(
|
||||||
item.trendText +
|
mainAxisSize: MainAxisSize.min,
|
||||||
(item.trendPeriod.isNotEmpty
|
children: [
|
||||||
? ' vs ${item.trendPeriod}'
|
Icon(
|
||||||
: ''),
|
up
|
||||||
style: const TextStyle(
|
? Icons.arrow_upward_rounded
|
||||||
fontSize: 10,
|
: Icons.arrow_downward_rounded,
|
||||||
fontWeight: FontWeight.w600,
|
size: 9,
|
||||||
color: AppColors.success,
|
color:
|
||||||
),
|
up ? AppColors.success : AppColors.error,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 2),
|
||||||
|
Text(
|
||||||
|
item.trendText.contains('%')
|
||||||
|
? item.trendText
|
||||||
|
: '${item.trendText}%',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: up
|
||||||
|
? AppColors.success
|
||||||
|
: AppColors.error,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (vsLabel.isNotEmpty) ...[
|
||||||
|
const SizedBox(width: 3),
|
||||||
|
Text(
|
||||||
|
'vs $vsLabel',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 9,
|
||||||
|
color: _kSlate400,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@ -2334,18 +2495,34 @@ class _IndicatorRow extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
Column(
|
||||||
Text(
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
item.value.isEmpty ? '—' : item.value,
|
mainAxisSize: MainAxisSize.min,
|
||||||
style: TextStyle(
|
children: [
|
||||||
fontSize: 18,
|
Text(
|
||||||
fontWeight: FontWeight.w700,
|
item.value.isEmpty ? '—' : item.value,
|
||||||
color: accentColor,
|
style: const TextStyle(
|
||||||
fontFeatures: const [FontFeature.tabularFigures()],
|
fontSize: 18,
|
||||||
),
|
fontWeight: FontWeight.w700,
|
||||||
|
color: _kSlate900,
|
||||||
|
fontFeatures: [FontFeature.tabularFigures()],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(
|
||||||
|
width: 60,
|
||||||
|
height: 18,
|
||||||
|
child: CustomPaint(
|
||||||
|
painter: _SparklinePainter(
|
||||||
|
points: item.sparklineValues,
|
||||||
|
isUp: up,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
const Icon(Icons.arrow_forward_ios_rounded, size: 13, color: _kSlate400),
|
const Icon(Icons.arrow_forward_ios_rounded,
|
||||||
|
size: 13, color: _kSlate400),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user