← Documentação |

Exemplos de KPIs — Flutter / Dart

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),
          ),
        )),
      )),
    );
  }
}