691 lines
26 KiB
Dart
691 lines
26 KiB
Dart
import 'package:flutter/material.dart';
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
import 'package:intl/intl.dart';
|
||
|
||
import '../../core/providers/auth_provider.dart';
|
||
import '../../core/theme/app_theme.dart';
|
||
import '../../core/widgets/gradient_app_bar.dart';
|
||
import '../../models/job.dart';
|
||
import '../../models/tenant.dart';
|
||
import 'reports_repository.dart';
|
||
|
||
class ReportsScreen extends ConsumerStatefulWidget {
|
||
const ReportsScreen({super.key});
|
||
|
||
@override
|
||
ConsumerState<ReportsScreen> createState() => _ReportsScreenState();
|
||
}
|
||
|
||
class _ReportsScreenState extends ConsumerState<ReportsScreen>
|
||
with SingleTickerProviderStateMixin {
|
||
late TabController _tabController;
|
||
late Future<ReportMetrics> _future;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_tabController = TabController(length: 4, vsync: this);
|
||
_tabController.addListener(() { if (mounted) setState(() {}); });
|
||
_load();
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_tabController.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
void _load() {
|
||
final auth = ref.read(authProvider);
|
||
final tenantId = auth.activeTenant!.tenant.id;
|
||
final kind = auth.activeTenant!.tenant.kind;
|
||
setState(() {
|
||
_future = ReportsRepository.instance.load(tenantId, kind);
|
||
});
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final kind = ref.watch(authProvider).activeTenant?.tenant.kind ?? TenantKind.lab;
|
||
final counterpartLabel = kind == TenantKind.lab ? 'Klinikler' : 'Laboratuvarlar';
|
||
|
||
return Scaffold(
|
||
backgroundColor: AppColors.background,
|
||
appBar: GradientAppBar(
|
||
title: 'Raporlar',
|
||
category: 'YÖNETİCİ',
|
||
actions: [
|
||
IconButton(
|
||
icon: const Icon(Icons.refresh_rounded),
|
||
onPressed: _load,
|
||
tooltip: 'Yenile',
|
||
),
|
||
],
|
||
),
|
||
body: FutureBuilder<ReportMetrics>(
|
||
future: _future,
|
||
builder: (context, snap) {
|
||
if (snap.connectionState == ConnectionState.waiting) {
|
||
return const Center(child: CircularProgressIndicator(color: AppColors.accent));
|
||
}
|
||
if (snap.hasError) {
|
||
return Center(
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
const Icon(Icons.error_outline_rounded, color: AppColors.cancelled, size: 48),
|
||
const SizedBox(height: 12),
|
||
Text('Veriler yüklenemedi', style: const TextStyle(color: AppColors.textSecondary)),
|
||
const SizedBox(height: 12),
|
||
FilledButton.icon(
|
||
onPressed: _load,
|
||
icon: const Icon(Icons.refresh_rounded, size: 18),
|
||
label: const Text('Tekrar Dene'),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
final m = snap.data!;
|
||
return Column(
|
||
children: [
|
||
// Tab bar
|
||
Container(
|
||
color: AppColors.surface,
|
||
child: TabBar(
|
||
controller: _tabController,
|
||
labelColor: AppColors.accent,
|
||
unselectedLabelColor: AppColors.textSecondary,
|
||
indicatorColor: AppColors.accent,
|
||
indicatorSize: TabBarIndicatorSize.tab,
|
||
labelStyle: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
|
||
tabs: [
|
||
const Tab(text: 'Özet'),
|
||
const Tab(text: 'Finans'),
|
||
const Tab(text: 'Aktivite'),
|
||
Tab(text: counterpartLabel),
|
||
],
|
||
),
|
||
),
|
||
Expanded(
|
||
child: TabBarView(
|
||
controller: _tabController,
|
||
children: [
|
||
_SummaryTab(metrics: m),
|
||
_FinanceTab(metrics: m),
|
||
_ActivityTab(metrics: m),
|
||
_CounterpartTab(metrics: m, label: counterpartLabel),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
);
|
||
},
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── Shared layout helpers ─────────────────────────────────────────────────────
|
||
|
||
class _TabBody extends StatelessWidget {
|
||
const _TabBody({required this.children});
|
||
final List<Widget> children;
|
||
|
||
@override
|
||
Widget build(BuildContext context) => ListView(
|
||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 32),
|
||
children: children,
|
||
);
|
||
}
|
||
|
||
class _Card extends StatelessWidget {
|
||
const _Card({required this.child, this.padding = const EdgeInsets.all(16)});
|
||
final Widget child;
|
||
final EdgeInsets padding;
|
||
|
||
@override
|
||
Widget build(BuildContext context) => Container(
|
||
margin: const EdgeInsets.only(bottom: 12),
|
||
padding: padding,
|
||
decoration: BoxDecoration(
|
||
color: AppColors.surface,
|
||
borderRadius: BorderRadius.circular(16),
|
||
border: Border.all(color: AppColors.border),
|
||
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.03), blurRadius: 8, offset: const Offset(0, 2))],
|
||
),
|
||
child: child,
|
||
);
|
||
}
|
||
|
||
class _SectionHeader extends StatelessWidget {
|
||
const _SectionHeader(this.title, {this.subtitle});
|
||
final String title;
|
||
final String? subtitle;
|
||
|
||
@override
|
||
Widget build(BuildContext context) => Padding(
|
||
padding: const EdgeInsets.only(bottom: 10, top: 4),
|
||
child: Row(
|
||
children: [
|
||
Expanded(
|
||
child: Text(title, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w700, color: AppColors.textPrimary)),
|
||
),
|
||
if (subtitle != null)
|
||
Text(subtitle!, style: const TextStyle(fontSize: 12, color: AppColors.textMuted)),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
// ── KPI Chips ─────────────────────────────────────────────────────────────────
|
||
|
||
class _KpiRow extends StatelessWidget {
|
||
const _KpiRow({required this.metrics});
|
||
final ReportMetrics metrics;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final fmt = NumberFormat.currency(locale: 'tr_TR', symbol: metrics.currency, decimalDigits: 0);
|
||
return SingleChildScrollView(
|
||
scrollDirection: Axis.horizontal,
|
||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||
child: Row(
|
||
children: [
|
||
_Kpi(label: 'Aktif İşler', value: '${metrics.activeJobs}', icon: Icons.work_outline_rounded, color: AppColors.inProgress),
|
||
const SizedBox(width: 10),
|
||
_Kpi(label: 'Bu Ay Tamamlandı', value: '${metrics.completedThisMonth}', icon: Icons.check_circle_outline_rounded, color: AppColors.success),
|
||
const SizedBox(width: 10),
|
||
_Kpi(label: 'Bekleyen Gelir', value: fmt.format(metrics.pendingRevenue), icon: Icons.hourglass_empty_rounded, color: AppColors.pending),
|
||
const SizedBox(width: 10),
|
||
_Kpi(label: 'Ort. Süre', value: '${metrics.avgCompletionDays.toStringAsFixed(1)} gün', icon: Icons.timer_outlined, color: AppColors.accent),
|
||
const SizedBox(width: 10),
|
||
_Kpi(label: 'Revizyon Oranı', value: '%${metrics.revisionRate.toStringAsFixed(0)}', icon: Icons.loop_rounded, color: metrics.revisionRate > 20 ? AppColors.cancelled : AppColors.textSecondary),
|
||
if (metrics.overdueJobs > 0) ...[
|
||
const SizedBox(width: 10),
|
||
_Kpi(label: 'Gecikmiş', value: '${metrics.overdueJobs}', icon: Icons.schedule_rounded, color: AppColors.cancelled),
|
||
],
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _Kpi extends StatelessWidget {
|
||
const _Kpi({required this.label, required this.value, required this.icon, required this.color});
|
||
final String label, value;
|
||
final IconData icon;
|
||
final Color color;
|
||
|
||
@override
|
||
Widget build(BuildContext context) => Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||
decoration: BoxDecoration(
|
||
color: AppColors.surface,
|
||
borderRadius: BorderRadius.circular(14),
|
||
border: Border.all(color: AppColors.border),
|
||
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.03), blurRadius: 6)],
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Icon(icon, size: 14, color: color),
|
||
const SizedBox(width: 4),
|
||
Text(label, style: const TextStyle(fontSize: 11, color: AppColors.textMuted)),
|
||
],
|
||
),
|
||
const SizedBox(height: 6),
|
||
Text(value, style: TextStyle(fontSize: 20, fontWeight: FontWeight.w800, color: color)),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
// ── Özet Tab ──────────────────────────────────────────────────────────────────
|
||
|
||
class _SummaryTab extends StatelessWidget {
|
||
const _SummaryTab({required this.metrics});
|
||
final ReportMetrics metrics;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return _TabBody(children: [
|
||
_KpiRow(metrics: metrics),
|
||
const _SectionHeader('İş Durumu Dağılımı'),
|
||
_Card(
|
||
child: Column(
|
||
children: _statusOrder.where((s) => metrics.jobsByStatus.containsKey(s)).map((s) {
|
||
final count = metrics.jobsByStatus[s] ?? 0;
|
||
final total = metrics.jobsByStatus.values.fold(0, (a, b) => a + b);
|
||
return _HBarRow(
|
||
label: _statusLabel(s),
|
||
value: count,
|
||
max: total,
|
||
color: _statusColor(s),
|
||
);
|
||
}).toList(),
|
||
),
|
||
),
|
||
const _SectionHeader('Son 6 Aylık İş Trendi'),
|
||
_Card(child: _VBarChart(data: metrics.monthlyCounts, color: AppColors.accent)),
|
||
]);
|
||
}
|
||
|
||
static const _statusOrder = ['in_progress', 'pending', 'sent', 'delivered', 'cancelled'];
|
||
|
||
static String _statusLabel(String s) => switch (s) {
|
||
'pending' => 'Bekliyor',
|
||
'in_progress' => 'İşlemde',
|
||
'sent' => 'Gönderildi',
|
||
'delivered' => 'Teslim',
|
||
'cancelled' => 'İptal',
|
||
_ => s,
|
||
};
|
||
|
||
static Color _statusColor(String s) => switch (s) {
|
||
'pending' => AppColors.pending,
|
||
'in_progress' => AppColors.inProgress,
|
||
'sent' => AppColors.accent,
|
||
'delivered' => AppColors.success,
|
||
'cancelled' => AppColors.cancelled,
|
||
_ => AppColors.textMuted,
|
||
};
|
||
}
|
||
|
||
// ── Finans Tab ────────────────────────────────────────────────────────────────
|
||
|
||
class _FinanceTab extends StatelessWidget {
|
||
const _FinanceTab({required this.metrics});
|
||
final ReportMetrics metrics;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final fmt = NumberFormat.currency(locale: 'tr_TR', symbol: metrics.currency, decimalDigits: 0);
|
||
final total = metrics.totalRevenue + metrics.pendingRevenue;
|
||
return _TabBody(children: [
|
||
const _SectionHeader('Gelir Özeti'),
|
||
Row(
|
||
children: [
|
||
Expanded(
|
||
child: _Card(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(children: [
|
||
Container(width: 8, height: 8, decoration: BoxDecoration(color: AppColors.success, shape: BoxShape.circle)),
|
||
const SizedBox(width: 6),
|
||
const Text('Tahsil Edildi', style: TextStyle(fontSize: 12, color: AppColors.textMuted)),
|
||
]),
|
||
const SizedBox(height: 6),
|
||
Text(fmt.format(metrics.totalRevenue),
|
||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w800, color: AppColors.success)),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(width: 10),
|
||
Expanded(
|
||
child: _Card(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(children: [
|
||
Container(width: 8, height: 8, decoration: BoxDecoration(color: AppColors.pending, shape: BoxShape.circle)),
|
||
const SizedBox(width: 6),
|
||
const Text('Bekleyen', style: TextStyle(fontSize: 12, color: AppColors.textMuted)),
|
||
]),
|
||
const SizedBox(height: 6),
|
||
Text(fmt.format(metrics.pendingRevenue),
|
||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w800, color: AppColors.pending)),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
if (total > 0) _Card(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
const Text('Tahsilat Oranı', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600)),
|
||
const SizedBox(height: 10),
|
||
ClipRRect(
|
||
borderRadius: BorderRadius.circular(6),
|
||
child: LinearProgressIndicator(
|
||
value: total > 0 ? metrics.totalRevenue / total : 0,
|
||
minHeight: 12,
|
||
backgroundColor: AppColors.pendingBg,
|
||
valueColor: const AlwaysStoppedAnimation<Color>(AppColors.success),
|
||
),
|
||
),
|
||
const SizedBox(height: 6),
|
||
Text('${(metrics.totalRevenue / total * 100).toStringAsFixed(0)}% tahsil edildi',
|
||
style: const TextStyle(fontSize: 12, color: AppColors.textMuted)),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(height: 4),
|
||
const _SectionHeader('Aylık Gelir Trendi'),
|
||
_Card(child: _VBarChart(
|
||
data: metrics.monthlyRevenue.map((m) => MonthlyCount(year: m.year, month: m.month, count: m.amount.round())).toList(),
|
||
color: AppColors.success,
|
||
formatValue: (v) => NumberFormat.compactCurrency(locale: 'tr_TR', symbol: metrics.currency, decimalDigits: 0).format(v),
|
||
)),
|
||
]);
|
||
}
|
||
}
|
||
|
||
// ── Aktivite Tab ──────────────────────────────────────────────────────────────
|
||
|
||
class _ActivityTab extends StatelessWidget {
|
||
const _ActivityTab({required this.metrics});
|
||
final ReportMetrics metrics;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final items = metrics.recentActivity;
|
||
if (items.isEmpty) {
|
||
return const Center(
|
||
child: Text('Henüz aktivite kaydı yok.', style: TextStyle(color: AppColors.textMuted)),
|
||
);
|
||
}
|
||
return _TabBody(children: [
|
||
const _SectionHeader('Son İşlemler'),
|
||
_Card(
|
||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||
child: Column(
|
||
children: items.asMap().entries.map((entry) {
|
||
final i = entry.key;
|
||
final item = entry.value;
|
||
return _ActivityRow(item: item, isLast: i == items.length - 1);
|
||
}).toList(),
|
||
),
|
||
),
|
||
]);
|
||
}
|
||
}
|
||
|
||
class _ActivityRow extends StatelessWidget {
|
||
const _ActivityRow({required this.item, required this.isLast});
|
||
final ActivityItem item;
|
||
final bool isLast;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final color = item.isNegative ? AppColors.cancelled : item.isPositive ? AppColors.success : AppColors.accent;
|
||
final icon = switch (item.action) {
|
||
'accepted' => Icons.check_circle_outline_rounded,
|
||
'handed_to_clinic' => Icons.send_rounded,
|
||
'approved' => Icons.thumb_up_outlined,
|
||
'revision_requested' => Icons.loop_rounded,
|
||
'delivered' => Icons.local_shipping_outlined,
|
||
'cancelled' => Icons.cancel_outlined,
|
||
_ => Icons.history_rounded,
|
||
};
|
||
final df = DateFormat('dd.MM.yy HH:mm');
|
||
|
||
return IntrinsicHeight(
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
// Timeline line + dot
|
||
SizedBox(
|
||
width: 28,
|
||
child: Column(
|
||
children: [
|
||
Container(
|
||
width: 24, height: 24,
|
||
decoration: BoxDecoration(
|
||
color: color.withValues(alpha: 0.12),
|
||
shape: BoxShape.circle,
|
||
),
|
||
child: Icon(icon, size: 13, color: color),
|
||
),
|
||
if (!isLast)
|
||
Expanded(child: Container(width: 1.5, color: AppColors.border)),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(width: 10),
|
||
Expanded(
|
||
child: Padding(
|
||
padding: EdgeInsets.only(bottom: isLast ? 0 : 10),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(item.actionLabel,
|
||
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: AppColors.textPrimary)),
|
||
if (item.patientCode != null && item.patientCode!.isNotEmpty)
|
||
Text(item.patientCode!, style: const TextStyle(fontSize: 11, color: AppColors.accent)),
|
||
if (item.note != null && item.note!.isNotEmpty)
|
||
Text(item.note!, style: const TextStyle(fontSize: 11, color: AppColors.textMuted)),
|
||
const SizedBox(height: 2),
|
||
Text(df.format(item.createdAt), style: const TextStyle(fontSize: 10, color: AppColors.textMuted)),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── Counterpart Tab ───────────────────────────────────────────────────────────
|
||
|
||
class _CounterpartTab extends StatelessWidget {
|
||
const _CounterpartTab({required this.metrics, required this.label});
|
||
final ReportMetrics metrics;
|
||
final String label;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final stats = metrics.counterpartStats;
|
||
if (stats.isEmpty) {
|
||
return Center(
|
||
child: Text('$label henüz yok.', style: const TextStyle(color: AppColors.textMuted)),
|
||
);
|
||
}
|
||
final fmt = NumberFormat.currency(locale: 'tr_TR', symbol: metrics.currency, decimalDigits: 0);
|
||
final maxJobs = stats.fold(0, (m, s) => s.jobCount > m ? s.jobCount : m);
|
||
|
||
return _TabBody(children: [
|
||
const _SectionHeader('Protez Türü Dağılımı'),
|
||
_Card(
|
||
child: Column(
|
||
children: _buildTypeRows(metrics.byProstheticType),
|
||
),
|
||
),
|
||
_SectionHeader('En Aktif $label'),
|
||
_Card(
|
||
child: Column(
|
||
children: stats.asMap().entries.map((entry) {
|
||
final i = entry.key;
|
||
final s = entry.value;
|
||
return Padding(
|
||
padding: EdgeInsets.only(bottom: i < stats.length - 1 ? 12 : 0),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
Container(
|
||
width: 22, height: 22,
|
||
decoration: BoxDecoration(
|
||
color: AppColors.inProgressBg,
|
||
shape: BoxShape.circle,
|
||
),
|
||
child: Center(
|
||
child: Text('${i + 1}',
|
||
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.w700, color: AppColors.inProgress)),
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
Expanded(
|
||
child: Text(s.name,
|
||
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: AppColors.textPrimary),
|
||
maxLines: 1, overflow: TextOverflow.ellipsis),
|
||
),
|
||
Text('${s.jobCount} iş',
|
||
style: const TextStyle(fontSize: 12, color: AppColors.textSecondary, fontWeight: FontWeight.w500)),
|
||
],
|
||
),
|
||
const SizedBox(height: 6),
|
||
ClipRRect(
|
||
borderRadius: BorderRadius.circular(4),
|
||
child: LinearProgressIndicator(
|
||
value: maxJobs > 0 ? s.jobCount / maxJobs : 0,
|
||
minHeight: 6,
|
||
backgroundColor: AppColors.surfaceVariant,
|
||
valueColor: const AlwaysStoppedAnimation<Color>(AppColors.accent),
|
||
),
|
||
),
|
||
if (s.totalRevenue > 0) Padding(
|
||
padding: const EdgeInsets.only(top: 4),
|
||
child: Text(
|
||
'${fmt.format(s.paidRevenue)} tahsil · ${fmt.format(s.pendingRevenue)} bekliyor',
|
||
style: const TextStyle(fontSize: 11, color: AppColors.textMuted),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}).toList(),
|
||
),
|
||
),
|
||
]);
|
||
}
|
||
|
||
List<Widget> _buildTypeRows(Map<String, int> byType) {
|
||
if (byType.isEmpty) return [const Text('Veri yok', style: TextStyle(color: AppColors.textMuted))];
|
||
final sorted = byType.entries.toList()..sort((a, b) => b.value.compareTo(a.value));
|
||
final max = sorted.first.value;
|
||
return sorted.map((e) => _HBarRow(
|
||
label: _typeLabel(e.key),
|
||
value: e.value,
|
||
max: max,
|
||
color: AppColors.accent,
|
||
)).toList();
|
||
}
|
||
|
||
static String _typeLabel(String s) => switch (s) {
|
||
'metal_porselen' => 'Metal Porselen',
|
||
'zirkonyum' => 'Zirkonyum',
|
||
'implant_ustu_zirkonyum'=> 'İmplant Üstü',
|
||
'gecici' => 'Geçici',
|
||
'e_max' => 'E-Max',
|
||
'tam_protez' => 'Tam Protez',
|
||
'parsiyel' => 'Parsiyel',
|
||
_ => 'Diğer',
|
||
};
|
||
}
|
||
|
||
// ── Chart widgets ─────────────────────────────────────────────────────────────
|
||
|
||
class _HBarRow extends StatelessWidget {
|
||
const _HBarRow({required this.label, required this.value, required this.max, required this.color});
|
||
final String label;
|
||
final int value, max;
|
||
final Color color;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final fraction = max > 0 ? value / max : 0.0;
|
||
return Padding(
|
||
padding: const EdgeInsets.symmetric(vertical: 5),
|
||
child: Row(
|
||
children: [
|
||
SizedBox(
|
||
width: 100,
|
||
child: Text(label,
|
||
style: const TextStyle(fontSize: 12, color: AppColors.textSecondary),
|
||
maxLines: 1, overflow: TextOverflow.ellipsis),
|
||
),
|
||
Expanded(
|
||
child: ClipRRect(
|
||
borderRadius: BorderRadius.circular(4),
|
||
child: Stack(
|
||
children: [
|
||
Container(height: 22, color: AppColors.surfaceVariant),
|
||
FractionallySizedBox(
|
||
widthFactor: fraction,
|
||
child: Container(
|
||
height: 22,
|
||
decoration: BoxDecoration(
|
||
color: color.withValues(alpha: 0.8),
|
||
borderRadius: BorderRadius.circular(4),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
SizedBox(
|
||
width: 28,
|
||
child: Text('$value',
|
||
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w700, color: AppColors.textPrimary),
|
||
textAlign: TextAlign.right),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _VBarChart extends StatelessWidget {
|
||
const _VBarChart({required this.data, required this.color, this.formatValue});
|
||
final List<MonthlyCount> data;
|
||
final Color color;
|
||
final String Function(int)? formatValue;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
if (data.isEmpty) return const SizedBox.shrink();
|
||
final maxVal = data.fold(0, (m, e) => e.count > m ? e.count : m);
|
||
final fmt = formatValue ?? (v) => '$v';
|
||
|
||
return SizedBox(
|
||
height: 140,
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.end,
|
||
children: data.map((d) {
|
||
final fraction = maxVal > 0 ? d.count / maxVal : 0.0;
|
||
return Expanded(
|
||
child: Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 2),
|
||
child: Column(
|
||
mainAxisAlignment: MainAxisAlignment.end,
|
||
children: [
|
||
if (d.count > 0)
|
||
Text(fmt(d.count),
|
||
style: const TextStyle(fontSize: 9, color: AppColors.textMuted),
|
||
textAlign: TextAlign.center),
|
||
const SizedBox(height: 2),
|
||
AnimatedContainer(
|
||
duration: const Duration(milliseconds: 600),
|
||
curve: Curves.easeOut,
|
||
height: (fraction * 90).clamp(2, 90),
|
||
decoration: BoxDecoration(
|
||
color: color,
|
||
borderRadius: const BorderRadius.vertical(top: Radius.circular(4)),
|
||
),
|
||
),
|
||
const SizedBox(height: 4),
|
||
Text(d.label,
|
||
style: const TextStyle(fontSize: 10, color: AppColors.textMuted),
|
||
textAlign: TextAlign.center),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}).toList(),
|
||
),
|
||
);
|
||
}
|
||
}
|