pubspec.yaml:
dependencies:
dio: ^5.4.0
fl_chart: ^0.67.0
provider: ^6.1.0
intl: ^0.19.0
// lib/services/api_service.dart
import 'package:dio/dio.dart';
class ApiService {
late final Dio _dio;
ApiService({
required String baseUrl,
required String token,
required String operatorId,
}) {
_dio = Dio(BaseOptions(
baseUrl: baseUrl, // ex: https://api.aguas.mz/api
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 30),
headers: {
'Authorization': 'Bearer $token',
'X-Operator-Id': operatorId,
'Accept': 'application/json',
'Content-Type': 'application/json',
},
));
_dio.interceptors.add(LogInterceptor(responseBody: false));
}
// ── Auth ───────────────────────────────────────────────
Future<Map<String, dynamic>> login(String email, String password) async {
final res = await _dio.post('/auth/login', data: {
'email': email, 'password': password,
});
return res.data;
}
// ── KPIs ───────────────────────────────────────────────
Future<KpiSummary> fetchSummary(String period) async {
final res = await _dio.get('/kpi/summary', queryParameters: {'period': period});
return KpiSummary.fromJson(res.data);
}
Future<List<RevenuePoint>> fetchRevenue({
required String from,
required String to,
String groupBy = 'month',
}) async {
final res = await _dio.get('/kpi/revenue', queryParameters: {
'from': from, 'to': to, 'group_by': groupBy,
});
return (res.data['data'] as List)
.map((e) => RevenuePoint.fromJson(e))
.toList();
}
Future<List<TechnicianRank>> fetchTechnicianRanking(String period) async {
final res = await _dio.get('/kpi/technicians', queryParameters: {'period': period});
return (res.data as List).map((e) => TechnicianRank.fromJson(e)).toList();
}
Future<ReadingKpi> fetchReadings(String period) async {
final res = await _dio.get('/kpi/readings', queryParameters: {'period': period});
return ReadingKpi.fromJson(res.data);
}
}
// lib/models/kpi_models.dart
class KpiSummary {
final int totalConsumers;
final int activeContracts;
final double revenueMtd;
final double collectionRate;
final int overdueInvoices;
final double overdueAmount;
final int readingsThisMonth;
final double readingCoverage;
const KpiSummary({
required this.totalConsumers,
required this.activeContracts,
required this.revenueMtd,
required this.collectionRate,
required this.overdueInvoices,
required this.overdueAmount,
required this.readingsThisMonth,
required this.readingCoverage,
});
factory KpiSummary.fromJson(Map<String, dynamic> json) => KpiSummary(
totalConsumers: (json['total_consumers'] as num).toInt(),
activeContracts: (json['active_contracts'] as num).toInt(),
revenueMtd: (json['revenue_mtd'] as num).toDouble(),
collectionRate: (json['collection_rate'] as num).toDouble(),
overdueInvoices: (json['overdue_invoices'] as num).toInt(),
overdueAmount: (json['overdue_amount'] as num).toDouble(),
readingsThisMonth: (json['readings_this_month'] as num).toInt(),
readingCoverage: (json['reading_coverage'] as num).toDouble(),
);
}
class RevenuePoint {
final String period;
final double amount;
RevenuePoint({required this.period, required this.amount});
factory RevenuePoint.fromJson(Map<String, dynamic> j) =>
RevenuePoint(period: j['period'], amount: (j['amount'] as num).toDouble());
}
class TechnicianRank {
final int rank;
final String name;
final int readingsCount;
final double score;
TechnicianRank({required this.rank, required this.name, required this.readingsCount, required this.score});
factory TechnicianRank.fromJson(Map<String, dynamic> j) => TechnicianRank(
rank: (j['rank'] as num).toInt(),
name: j['name'],
readingsCount: (j['readings_count'] as num).toInt(),
score: (j['score'] as num).toDouble(),
);
}
class ReadingKpi {
final int total, real, estimated;
final double coverageRate, totalConsumption;
ReadingKpi({required this.total, required this.real, required this.estimated, required this.coverageRate, required this.totalConsumption});
factory ReadingKpi.fromJson(Map<String, dynamic> j) => ReadingKpi(
total: (j['total'] as num).toInt(),
real: (j['real'] as num).toInt(),
estimated: (j['estimated'] as num).toInt(),
coverageRate: (j['coverage_rate'] as num).toDouble(),
totalConsumption: (j['total_consumption'] as num).toDouble(),
);
}
// lib/providers/kpi_provider.dart
import 'package:flutter/material.dart';
import '../services/api_service.dart';
import '../models/kpi_models.dart';
class KpiProvider extends ChangeNotifier {
final ApiService _api;
KpiProvider(this._api);
KpiSummary? summary;
List<RevenuePoint> revenue = [];
List<TechnicianRank> technicians = [];
ReadingKpi? readings;
bool isLoading = false;
String? error;
String get currentPeriod {
final now = DateTime.now();
return '${now.year}-${now.month.toString().padLeft(2, '0')}';
}
Future<void> loadAll() async {
isLoading = true; error = null; notifyListeners();
try {
await Future.wait([
_loadSummary(),
_loadRevenue(),
_loadTechnicians(),
_loadReadings(),
]);
} catch (e) {
error = e.toString();
} finally {
isLoading = false; notifyListeners();
}
}
Future<void> _loadSummary() async {
summary = await _api.fetchSummary(currentPeriod);
}
Future<void> _loadRevenue() async {
final year = DateTime.now().year;
revenue = await _api.fetchRevenue(
from: '$year-01-01', to: '$year-12-31',
);
}
Future<void> _loadTechnicians() async {
technicians = await _api.fetchTechnicianRanking(currentPeriod);
}
Future<void> _loadReadings() async {
readings = await _api.fetchReadings(currentPeriod);
}
}
// lib/screens/dashboard_screen.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:intl/intl.dart';
import '../providers/kpi_provider.dart';
class DashboardScreen extends StatefulWidget {
const DashboardScreen({super.key});
@override State<DashboardScreen> createState() => _DashboardScreenState();
}
class _DashboardScreenState extends State<DashboardScreen> {
@override
void initState() {
super.initState();
Future.microtask(() => context.read<KpiProvider>().loadAll());
}
@override
Widget build(BuildContext context) {
final kpi = context.watch<KpiProvider>();
final s = kpi.summary;
final fmt = NumberFormat.currency(locale: 'pt_MZ', symbol: 'MZN ', decimalDigits: 0);
if (kpi.isLoading) return const Center(child: CircularProgressIndicator());
if (kpi.error != null) return Center(child: Text('Erro: ${kpi.error}'));
return RefreshIndicator(
onRefresh: kpi.loadAll,
child: ListView(
padding: const EdgeInsets.all(16),
children: [
Text('Dashboard — ${kpi.currentPeriod}',
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
const SizedBox(height: 16),
// Grid 2x2 de KPI Cards
GridView.count(
crossAxisCount: 2, shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
crossAxisSpacing: 12, mainAxisSpacing: 12, childAspectRatio: 1.5,
children: [
_KpiCard('Consumidores', '${s?.totalConsumers ?? 0}', '${s?.activeContracts ?? 0} activos', Colors.blue),
_KpiCard('Receita MTD', fmt.format(s?.revenueMtd ?? 0), 'Cobrança: ${s?.collectionRate?.toStringAsFixed(1)}%', s != null && s.collectionRate >= 90 ? Colors.green : Colors.orange),
_KpiCard('Leituras', '${s?.readingsThisMonth ?? 0}', 'Cobertura: ${s?.readingCoverage?.toStringAsFixed(1)}%', Colors.indigo),
_KpiCard('Em Atraso', '${s?.overdueInvoices ?? 0}', fmt.format(s?.overdueAmount ?? 0), s != null && s.overdueInvoices > 0 ? Colors.red : Colors.green),
],
),
const SizedBox(height: 24),
// Lista de técnicos
const Text('Top Técnicos', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
...kpi.technicians.take(5).map((t) => _TechRow(t)),
],
),
);
}
}
class _KpiCard extends StatelessWidget {
final String label, value, sub;
final Color color;
const _KpiCard(this.label, this.value, this.sub, this.color);
@override
Widget build(BuildContext context) => Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border(left: BorderSide(color: color, width: 4)),
boxShadow: [BoxShadow(color: Colors.black12, blurRadius: 4)],
),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(label, style: TextStyle(fontSize: 11, color: Colors.grey[600])),
Text(value, style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: color)),
Text(sub, style: const TextStyle(fontSize: 11, color: Colors.grey)),
]),
);
}
class _TechRow extends StatelessWidget {
final TechnicianRank t;
const _TechRow(this.t);
@override
Widget build(BuildContext context) => ListTile(
leading: CircleAvatar(
backgroundColor: t.rank == 1 ? Colors.amber[100] : Colors.grey[100],
child: Text('#${t.rank}', style: TextStyle(fontSize: 12, color: t.rank == 1 ? Colors.amber[800] : Colors.grey[700])),
),
title: Text(t.name),
subtitle: LinearProgressIndicator(value: t.score / 100, color: t.score >= 80 ? Colors.green : t.score >= 60 ? Colors.orange : Colors.red),
trailing: Text('${t.score.toStringAsFixed(1)}', style: const TextStyle(fontWeight: FontWeight.bold)),
);
}
// lib/widgets/revenue_bar_chart.dart
import 'package:flutter/material.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:provider/provider.dart';
import 'package:intl/intl.dart';
import '../providers/kpi_provider.dart';
class RevenueBarChart extends StatelessWidget {
const RevenueBarChart({super.key});
@override
Widget build(BuildContext context) {
final kpi = context.watch<KpiProvider>();
final revenue = kpi.revenue;
final maxVal = revenue.isEmpty ? 1.0 : revenue.map((r) => r.amount).reduce((a, b) => a > b ? a : b);
final months = ['Jan','Fev','Mar','Abr','Mai','Jun','Jul','Ago','Set','Out','Nov','Dez'];
List<BarChartGroupData> groups = [];
for (int i = 0; i < 12; i++) {
final m = (i + 1).toString().padLeft(2, '0');
final key = '${DateTime.now().year}-$m';
final val = revenue.firstWhere((r) => r.period == key, orElse: () => RevenuePoint(period: key, amount: 0)).amount;
groups.add(BarChartGroupData(x: i, barRods: [
BarChartRodData(toY: val, color: const Color(0xFF3B82F6), width: 14, borderRadius: BorderRadius.circular(4)),
]));
}
return Container(
height: 220, padding: const EdgeInsets.all(16),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(12), boxShadow: [BoxShadow(color: Colors.black12, blurRadius: 4)]),
child: BarChart(BarChartData(
maxY: maxVal * 1.2,
barGroups: groups,
gridData: FlGridData(show: true, horizontalInterval: maxVal / 4),
borderData: FlBorderData(show: false),
titlesData: FlTitlesData(
bottomTitles: AxisTitles(sideTitles: SideTitles(
showTitles: true, reservedSize: 28,
getTitlesWidget: (v, _) => Text(months[v.toInt()], style: const TextStyle(fontSize: 10)),
)),
leftTitles: AxisTitles(sideTitles: SideTitles(
showTitles: true, reservedSize: 44,
getTitlesWidget: (v, _) => Text('${(v / 1000).toStringAsFixed(0)}k', style: const TextStyle(fontSize: 9)),
)),
topTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)),
rightTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)),
),
barTouchData: BarTouchData(touchTooltipData: BarTouchTooltipData(
getTooltipItem: (group, _, rod, __) => BarTooltipItem(
'MZN ${NumberFormat.compact().format(rod.toY)}',
const TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
),
)),
)),
);
}
}