app mobile allarmi prima

This commit is contained in:
2025-10-20 19:17:45 +02:00
commit 300912ee02
159 changed files with 11755 additions and 0 deletions

212
lib/main.dart Normal file
View File

@@ -0,0 +1,212 @@
import 'package:flutter/material.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'utils/theme.dart';
import 'utils/constants.dart';
import 'services/auth_service.dart';
import 'services/api_service.dart';
import 'services/notification_service.dart';
import 'screens/login_screen.dart';
import 'screens/main_screen.dart';
import 'screens/allarme_detail_screen.dart';
// GlobalKey per accedere al Navigator
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Inizializza Firebase
await Firebase.initializeApp();
// Inizializza servizio notifiche
await NotificationService().initialize();
runApp(const TerrainMonitorApp());
}
class TerrainMonitorApp extends StatefulWidget {
const TerrainMonitorApp({super.key});
@override
State<TerrainMonitorApp> createState() => _TerrainMonitorAppState();
}
class _TerrainMonitorAppState extends State<TerrainMonitorApp> {
@override
void initState() {
super.initState();
_setupNotificationListener();
}
void _setupNotificationListener() {
// Listener per tap su notifiche
NotificationService().onMessageTap.listen((RemoteMessage message) {
_handleNotificationTap(message);
});
}
void _handleNotificationTap(RemoteMessage message) async {
final data = message.data;
print('Gestione tap notifica: $data');
// Naviga alla schermata di dettaglio allarme
if (data.containsKey('alarm_id')) {
final allarmeId = int.tryParse(data['alarm_id'] ?? '');
if (allarmeId != null) {
try {
// Fetch allarme dal server
final apiService = ApiService();
final allarme = await apiService.getAllarme(allarmeId);
navigatorKey.currentState?.push(
MaterialPageRoute(
builder: (context) => AllarmeDetailScreen(allarme: allarme),
),
);
} catch (e) {
print('Errore caricamento allarme: $e');
}
}
}
}
@override
Widget build(BuildContext context) {
return MaterialApp(
navigatorKey: navigatorKey,
title: AppConstants.appName,
debugShowCheckedModeBanner: false,
theme: AppTheme.lightTheme,
darkTheme: AppTheme.darkTheme,
themeMode: ThemeMode.light,
home: const SplashScreen(),
);
}
}
class SplashScreen extends StatefulWidget {
const SplashScreen({super.key});
@override
State<SplashScreen> createState() => _SplashScreenState();
}
class _SplashScreenState extends State<SplashScreen> {
@override
void initState() {
super.initState();
_checkAuth();
}
Future<void> _checkAuth() async {
// Attendi un momento per mostrare lo splash
await Future.delayed(const Duration(seconds: 2));
// Prova auto-login
final authService = AuthService();
final isAuthenticated = await authService.autoLogin();
if (mounted) {
Navigator.of(context).pushReplacement(
MaterialPageRoute(
builder: (context) =>
isAuthenticated ? const MainScreen() : const LoginScreen(),
),
);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Container(
decoration: BoxDecoration(
gradient: AppColors.primaryGradient,
),
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Logo
Container(
width: 150,
height: 150,
decoration: BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.2),
blurRadius: 30,
offset: const Offset(0, 15),
),
],
),
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.terrain,
size: 70,
color: AppColors.primary,
),
const SizedBox(height: 8),
Text(
'ASE',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: AppColors.primary,
),
),
],
),
),
),
const SizedBox(height: AppSizes.paddingXL),
// Titolo
const Text(
AppConstants.appName,
style: TextStyle(
fontSize: 36,
fontWeight: FontWeight.bold,
color: Colors.white,
letterSpacing: 2,
),
),
const SizedBox(height: AppSizes.paddingS),
// Sottotitolo
Text(
AppConstants.companyName,
style: TextStyle(
fontSize: 16,
color: Colors.white.withOpacity(0.9),
letterSpacing: 0.5,
),
),
const SizedBox(height: AppSizes.paddingXL * 2),
// Loading indicator
const CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
const SizedBox(height: AppSizes.paddingM),
Text(
'Caricamento...',
style: TextStyle(
color: Colors.white.withOpacity(0.8),
fontSize: 14,
),
),
],
),
),
),
);
}
}

111
lib/models/allarme.dart Normal file
View File

@@ -0,0 +1,111 @@
class Allarme {
final int id;
final int sitoId;
final String tipo;
final String severita;
final String stato;
final String titolo;
final String? descrizione;
final double? valoreRilevato;
final double? valoreSoglia;
final String? unitaMisura;
final Map<String, dynamic>? datiSensori;
final DateTime timestampRilevamento;
final DateTime createdAt;
Allarme({
required this.id,
required this.sitoId,
required this.tipo,
required this.severita,
required this.stato,
required this.titolo,
this.descrizione,
this.valoreRilevato,
this.valoreSoglia,
this.unitaMisura,
this.datiSensori,
required this.timestampRilevamento,
required this.createdAt,
});
factory Allarme.fromJson(Map<String, dynamic> json) {
return Allarme(
id: json['id'] as int,
sitoId: json['sito_id'] as int,
tipo: json['tipo'] as String,
severita: json['severita'] as String,
stato: json['stato'] as String,
titolo: json['titolo'] as String,
descrizione: json['descrizione'] as String?,
valoreRilevato: json['valore_rilevato'] != null
? (json['valore_rilevato'] as num).toDouble()
: null,
valoreSoglia: json['valore_soglia'] != null
? (json['valore_soglia'] as num).toDouble()
: null,
unitaMisura: json['unita_misura'] as String?,
datiSensori: json['dati_sensori'] as Map<String, dynamic>?,
timestampRilevamento: DateTime.parse(json['timestamp_rilevamento'] as String),
createdAt: DateTime.parse(json['created_at'] as String),
);
}
bool get isCritical => severita == 'critical';
bool get isWarning => severita == 'warning';
bool get isRisolto => stato == 'risolto';
String get tipoReadable {
final labels = {
'movimento_terreno': 'Movimento Terreno',
'deformazione': 'Deformazione',
'altro': 'Altro',
};
return labels[tipo] ?? tipo;
}
String get statoReadable {
final labels = {
'nuovo': 'Nuovo',
'in_gestione': 'In Gestione',
'risolto': 'Risolto',
};
return labels[stato] ?? stato;
}
String get severitaReadable {
final labels = {
'critical': 'CRITICO',
'warning': 'AVVISO',
'info': 'INFO',
};
return labels[severita] ?? severita.toUpperCase();
}
}
class AllarmeListResponse {
final int total;
final List<Allarme> items;
final int page;
final int pageSize;
AllarmeListResponse({
required this.total,
required this.items,
required this.page,
required this.pageSize,
});
factory AllarmeListResponse.fromJson(Map<String, dynamic> json) {
return AllarmeListResponse(
total: json['total'] as int,
items: (json['items'] as List)
.map((item) => Allarme.fromJson(item as Map<String, dynamic>))
.toList(),
page: json['page'] as int,
pageSize: json['page_size'] as int,
);
}
bool get hasMore => items.length < total;
}

81
lib/models/sito.dart Normal file
View File

@@ -0,0 +1,81 @@
class Sito {
final int id;
final int clienteId;
final String nome;
final String tipo;
final String? descrizione;
final double? latitudine;
final double? longitudine;
final double? altitudine;
final String? comune;
final String? provincia;
final String? regione;
final String? codiceIdentificativo;
final DateTime createdAt;
final DateTime? updatedAt;
Sito({
required this.id,
required this.clienteId,
required this.nome,
required this.tipo,
this.descrizione,
this.latitudine,
this.longitudine,
this.altitudine,
this.comune,
this.provincia,
this.regione,
this.codiceIdentificativo,
required this.createdAt,
this.updatedAt,
});
factory Sito.fromJson(Map<String, dynamic> json) {
return Sito(
id: json['id'] as int,
clienteId: json['cliente_id'] as int,
nome: json['nome'] as String,
tipo: json['tipo'] as String,
descrizione: json['descrizione'] as String?,
latitudine: json['latitudine'] != null
? (json['latitudine'] as num).toDouble()
: null,
longitudine: json['longitudine'] != null
? (json['longitudine'] as num).toDouble()
: null,
altitudine: json['altitudine'] != null
? (json['altitudine'] as num).toDouble()
: null,
comune: json['comune'] as String?,
provincia: json['provincia'] as String?,
regione: json['regione'] as String?,
codiceIdentificativo: json['codice_identificativo'] as String?,
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: json['updated_at'] != null
? DateTime.parse(json['updated_at'] as String)
: null,
);
}
String get tipoReadable {
final labels = {
'ponte': 'Ponte',
'galleria': 'Galleria',
'diga': 'Diga',
'frana': 'Frana',
'versante': 'Versante',
'edificio': 'Edificio',
};
return labels[tipo] ?? tipo;
}
String get localita {
final parts = <String>[];
if (comune != null) parts.add(comune!);
if (provincia != null) parts.add('($provincia)');
return parts.isNotEmpty ? parts.join(' ') : 'N/D';
}
bool get hasCoordinates => latitudine != null && longitudine != null;
}

103
lib/models/statistiche.dart Normal file
View File

@@ -0,0 +1,103 @@
class Statistiche {
final int totaleAllarmi;
final int totaleSiti;
final int allarmiAperti;
final int allarmiRecenti7gg;
// Per severità
final int allarmiCritical;
final int allarmiWarning;
final int allarmiInfo;
// Per stato
final int allarmiNuovo;
final int allarmiInGestione;
final int allarmiRisolto;
// Siti per tipo
final int sitiPonte;
final int sitiGalleria;
final int sitiDiga;
final int sitiFrana;
final int sitiVersante;
final int sitiEdificio;
Statistiche({
required this.totaleAllarmi,
required this.totaleSiti,
required this.allarmiAperti,
required this.allarmiRecenti7gg,
required this.allarmiCritical,
required this.allarmiWarning,
required this.allarmiInfo,
required this.allarmiNuovo,
required this.allarmiInGestione,
required this.allarmiRisolto,
required this.sitiPonte,
required this.sitiGalleria,
required this.sitiDiga,
required this.sitiFrana,
required this.sitiVersante,
required this.sitiEdificio,
});
factory Statistiche.fromJson(Map<String, dynamic> json) {
return Statistiche(
totaleAllarmi: json['totale_allarmi'] as int,
totaleSiti: json['totale_siti'] as int,
allarmiAperti: json['allarmi_aperti'] as int,
allarmiRecenti7gg: json['allarmi_recenti_7gg'] as int,
allarmiCritical: json['allarmi_critical'] as int,
allarmiWarning: json['allarmi_warning'] as int,
allarmiInfo: json['allarmi_info'] as int,
allarmiNuovo: json['allarmi_nuovo'] as int,
allarmiInGestione: json['allarmi_in_gestione'] as int,
allarmiRisolto: json['allarmi_risolto'] as int,
sitiPonte: json['siti_ponte'] as int,
sitiGalleria: json['siti_galleria'] as int,
sitiDiga: json['siti_diga'] as int,
sitiFrana: json['siti_frana'] as int,
sitiVersante: json['siti_versante'] as int,
sitiEdificio: json['siti_edificio'] as int,
);
}
int get totaleSeverita => allarmiCritical + allarmiWarning + allarmiInfo;
int get totaleStato => allarmiNuovo + allarmiInGestione + allarmiRisolto;
double get percentualeCritici =>
totaleAllarmi > 0 ? (allarmiCritical / totaleAllarmi) * 100 : 0;
double get percentualeAperti =>
totaleAllarmi > 0 ? (allarmiAperti / totaleAllarmi) * 100 : 0;
}
class AllarmePerGiorno {
final DateTime data;
final int count;
AllarmePerGiorno({
required this.data,
required this.count,
});
factory AllarmePerGiorno.fromJson(Map<String, dynamic> json) {
return AllarmePerGiorno(
data: DateTime.parse(json['data'] as String),
count: json['count'] as int,
);
}
}
class AllarmiPerGiornoResponse {
final List<AllarmePerGiorno> dati;
AllarmiPerGiornoResponse({required this.dati});
factory AllarmiPerGiornoResponse.fromJson(Map<String, dynamic> json) {
return AllarmiPerGiornoResponse(
dati: (json['dati'] as List)
.map((item) => AllarmePerGiorno.fromJson(item as Map<String, dynamic>))
.toList(),
);
}
}

41
lib/models/user.dart Normal file
View File

@@ -0,0 +1,41 @@
class User {
final int id;
final String email;
final String nome;
final String cognome;
final String ruolo;
final int clienteId;
User({
required this.id,
required this.email,
required this.nome,
required this.cognome,
required this.ruolo,
required this.clienteId,
});
factory User.fromJson(Map<String, dynamic> json) {
return User(
id: json['id'] as int,
email: json['email'] as String,
nome: json['nome'] as String,
cognome: json['cognome'] as String,
ruolo: json['ruolo'] as String,
clienteId: json['cliente_id'] as int,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'email': email,
'nome': nome,
'cognome': cognome,
'ruolo': ruolo,
'cliente_id': clienteId,
};
}
String get nomeCompleto => '$nome $cognome';
}

View File

@@ -0,0 +1,573 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:google_maps_flutter/google_maps_flutter.dart';
import 'dart:convert';
import '../models/allarme.dart';
import '../models/sito.dart';
import '../services/api_service.dart';
import '../utils/constants.dart';
class AllarmeDetailScreen extends StatefulWidget {
final Allarme allarme;
const AllarmeDetailScreen({super.key, required this.allarme});
@override
State<AllarmeDetailScreen> createState() => _AllarmeDetailScreenState();
}
class _AllarmeDetailScreenState extends State<AllarmeDetailScreen> {
final _apiService = ApiService();
late Allarme _allarme;
bool _isUpdating = false;
bool _isLoadingSito = true;
Sito? _sito;
GoogleMapController? _mapController;
@override
void initState() {
super.initState();
_allarme = widget.allarme;
_loadSito();
}
Future<void> _loadSito() async {
try {
final sito = await _apiService.getSito(_allarme.sitoId);
setState(() {
_sito = sito;
_isLoadingSito = false;
});
} catch (e) {
setState(() => _isLoadingSito = false);
}
}
@override
void dispose() {
_mapController?.dispose();
super.dispose();
}
Color _getSeverityColor() {
switch (_allarme.severita) {
case 'critical':
return AppColors.critical;
case 'warning':
return AppColors.warning;
case 'info':
return AppColors.info;
default:
return AppColors.textSecondary;
}
}
Future<void> _updateStato(String nuovoStato) async {
setState(() => _isUpdating = true);
try {
final updatedAllarme = await _apiService.updateAllarme(
_allarme.id,
stato: nuovoStato,
);
setState(() {
_allarme = updatedAllarme;
_isUpdating = false;
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Stato aggiornato a: ${_allarme.statoReadable}'),
backgroundColor: AppColors.success,
),
);
}
} catch (e) {
setState(() => _isUpdating = false);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Errore aggiornamento: $e'),
backgroundColor: AppColors.critical,
),
);
}
}
}
void _showStatoDialog() {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Cambia Stato'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildStatoOption('nuovo', 'Nuovo', Icons.fiber_new),
_buildStatoOption('in_gestione', 'In Gestione', Icons.engineering),
_buildStatoOption('risolto', 'Risolto', Icons.check_circle),
],
),
),
);
}
Widget _buildStatoOption(String stato, String label, IconData icon) {
final isCurrentStato = _allarme.stato == stato;
return ListTile(
leading: Icon(
icon,
color: isCurrentStato ? AppColors.primary : AppColors.textSecondary,
),
title: Text(
label,
style: TextStyle(
fontWeight: isCurrentStato ? FontWeight.bold : FontWeight.normal,
color: isCurrentStato ? AppColors.primary : null,
),
),
trailing: isCurrentStato
? const Icon(Icons.check, color: AppColors.primary)
: null,
onTap: isCurrentStato
? null
: () {
Navigator.pop(context);
_updateStato(stato);
},
);
}
@override
Widget build(BuildContext context) {
final dateFormat = DateFormat('dd/MM/yyyy HH:mm');
return Scaffold(
appBar: AppBar(
title: const Text('Dettaglio Allarme'),
actions: [
if (!_isUpdating)
IconButton(
icon: const Icon(Icons.edit),
onPressed: _showStatoDialog,
tooltip: 'Cambia stato',
),
],
),
body: _isUpdating
? const Center(child: CircularProgressIndicator())
: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header con severità
Container(
width: double.infinity,
padding: const EdgeInsets.all(AppSizes.paddingL),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
_getSeverityColor(),
_getSeverityColor().withOpacity(0.7),
],
),
),
child: SafeArea(
bottom: false,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
),
child: Text(
_allarme.severitaReadable,
style: TextStyle(
color: _getSeverityColor(),
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.9),
borderRadius: BorderRadius.circular(20),
),
child: Row(
children: [
Icon(
_getStatoIcon(),
size: 16,
color: _getStatoColor(),
),
const SizedBox(width: 4),
Text(
_allarme.statoReadable,
style: TextStyle(
color: _getStatoColor(),
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
],
),
),
],
),
const SizedBox(height: 16),
Text(
_allarme.titolo,
style: const TextStyle(
color: Colors.white,
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Row(
children: [
Icon(
AlarmTypeIcons.getIcon(_allarme.tipo),
color: Colors.white.withOpacity(0.9),
size: 18,
),
const SizedBox(width: 8),
Text(
_allarme.tipoReadable,
style: TextStyle(
color: Colors.white.withOpacity(0.9),
fontSize: 16,
),
),
],
),
],
),
),
),
// Informazioni principali
Padding(
padding: const EdgeInsets.all(AppSizes.paddingL),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Descrizione
if (_allarme.descrizione != null) ...[
const Text(
'Descrizione',
style: AppTextStyles.h3,
),
const SizedBox(height: 8),
Text(
_allarme.descrizione!,
style: AppTextStyles.bodyLarge,
),
const SizedBox(height: 24),
],
// Valori rilevati
if (_allarme.valoreRilevato != null &&
_allarme.valoreSoglia != null) ...[
const Text(
'Valori Rilevati',
style: AppTextStyles.h3,
),
const SizedBox(height: 12),
_buildInfoCard(
icon: Icons.analytics,
title: 'Valore Rilevato',
value:
'${_allarme.valoreRilevato!.toStringAsFixed(2)} ${_allarme.unitaMisura ?? ''}',
color: _getSeverityColor(),
),
const SizedBox(height: 8),
_buildInfoCard(
icon: Icons.straighten,
title: 'Soglia Impostata',
value:
'${_allarme.valoreSoglia!.toStringAsFixed(2)} ${_allarme.unitaMisura ?? ''}',
color: AppColors.textSecondary,
),
const SizedBox(height: 24),
],
// Dati sensori
if (_allarme.datiSensori != null &&
_allarme.datiSensori!.isNotEmpty) ...[
const Text(
'Dati Sensori',
style: AppTextStyles.h3,
),
const SizedBox(height: 12),
_buildSensorsData(),
const SizedBox(height: 24),
],
// Informazioni temporali
const Text(
'Informazioni Temporali',
style: AppTextStyles.h3,
),
const SizedBox(height: 12),
_buildInfoCard(
icon: Icons.access_time,
title: 'Rilevato il',
value: dateFormat.format(_allarme.timestampRilevamento),
),
const SizedBox(height: 8),
_buildInfoCard(
icon: Icons.schedule,
title: 'Creato il',
value: dateFormat.format(_allarme.createdAt),
),
const SizedBox(height: 24),
// Mappa con posizione sito
if (_sito != null && _sito!.hasCoordinates) ...[
const Text(
'Posizione Sito',
style: AppTextStyles.h3,
),
const SizedBox(height: 12),
_buildMapView(),
const SizedBox(height: 8),
_buildInfoCard(
icon: Icons.place,
title: 'Sito',
value: '${_sito!.nome} - ${_sito!.localita}',
),
const SizedBox(height: 24),
] else if (_isLoadingSito) ...[
const Center(
child: Padding(
padding: EdgeInsets.all(20.0),
child: CircularProgressIndicator(),
),
),
const SizedBox(height: 24),
],
// Dettagli tecnici
const Text(
'Dettagli Tecnici',
style: AppTextStyles.h3,
),
const SizedBox(height: 12),
_buildInfoCard(
icon: Icons.tag,
title: 'ID Allarme',
value: '#${_allarme.id}',
),
const SizedBox(height: 8),
_buildInfoCard(
icon: Icons.location_on,
title: 'ID Sito',
value: '#${_allarme.sitoId}',
),
],
),
),
],
),
),
bottomNavigationBar: !_isUpdating && _allarme.stato != 'risolto'
? SafeArea(
child: Padding(
padding: const EdgeInsets.all(AppSizes.paddingL),
child: ElevatedButton.icon(
onPressed: () => _updateStato('risolto'),
icon: const Icon(Icons.check_circle),
label: const Text('Segna come Risolto'),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.success,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 16),
),
),
),
)
: null,
);
}
Widget _buildInfoCard({
required IconData icon,
required String title,
required String value,
Color? color,
}) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey[100],
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey[300]!),
),
child: Row(
children: [
Icon(icon, color: color ?? AppColors.primary, size: 20),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: AppTextStyles.bodySmall.copyWith(
color: AppColors.textSecondary,
),
),
const SizedBox(height: 2),
Text(
value,
style: AppTextStyles.bodyMedium.copyWith(
fontWeight: FontWeight.w600,
),
),
],
),
),
],
),
);
}
Widget _buildSensorsData() {
final jsonEncoder = const JsonEncoder.withIndent(' ');
final prettyJson = jsonEncoder.convert(_allarme.datiSensori);
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.grey[900],
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(
Icons.sensors,
color: Colors.greenAccent,
size: 20,
),
const SizedBox(width: 8),
const Text(
'JSON Data',
style: TextStyle(
color: Colors.greenAccent,
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 12),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Text(
prettyJson,
style: const TextStyle(
fontFamily: 'monospace',
color: Colors.white,
fontSize: 12,
),
),
),
],
),
);
}
IconData _getStatoIcon() {
switch (_allarme.stato) {
case 'nuovo':
return Icons.fiber_new;
case 'in_gestione':
return Icons.engineering;
case 'risolto':
return Icons.check_circle;
default:
return Icons.info;
}
}
Color _getStatoColor() {
switch (_allarme.stato) {
case 'nuovo':
return AppColors.critical;
case 'in_gestione':
return AppColors.warning;
case 'risolto':
return AppColors.success;
default:
return AppColors.textSecondary;
}
}
Widget _buildMapView() {
if (_sito == null || !_sito!.hasCoordinates) {
return const SizedBox.shrink();
}
final position = LatLng(_sito!.latitudine!, _sito!.longitudine!);
return Container(
height: 200,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey[300]!),
),
clipBehavior: Clip.hardEdge,
child: GoogleMap(
initialCameraPosition: CameraPosition(
target: position,
zoom: 15,
),
markers: {
Marker(
markerId: MarkerId('sito_${_sito!.id}'),
position: position,
infoWindow: InfoWindow(
title: _sito!.nome,
snippet: _sito!.tipoReadable,
),
icon: BitmapDescriptor.defaultMarkerWithHue(
_getSeverityColor() == AppColors.critical
? BitmapDescriptor.hueRed
: _getSeverityColor() == AppColors.warning
? BitmapDescriptor.hueOrange
: BitmapDescriptor.hueBlue,
),
),
},
myLocationButtonEnabled: false,
zoomControlsEnabled: true,
mapToolbarEnabled: false,
onMapCreated: (controller) {
_mapController = controller;
},
),
);
}
}

View File

@@ -0,0 +1,619 @@
import 'package:flutter/material.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:intl/intl.dart';
import '../models/statistiche.dart';
import '../services/api_service.dart';
import '../utils/constants.dart';
class DashboardScreen extends StatefulWidget {
const DashboardScreen({super.key});
@override
State<DashboardScreen> createState() => _DashboardScreenState();
}
class _DashboardScreenState extends State<DashboardScreen> {
final _apiService = ApiService();
Statistiche? _statistiche;
AllarmiPerGiornoResponse? _allarmiPerGiorno;
bool _isLoading = true;
String? _errorMessage;
@override
void initState() {
super.initState();
_loadData();
}
Future<void> _loadData() async {
setState(() => _isLoading = true);
try {
final stats = await _apiService.getStatistiche();
final allarmiGiorno = await _apiService.getAllarmiPerGiorno(giorni: 30);
setState(() {
_statistiche = stats;
_allarmiPerGiorno = allarmiGiorno;
_isLoading = false;
_errorMessage = null;
});
} catch (e) {
setState(() {
_errorMessage = 'Errore caricamento dati: $e';
_isLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Dashboard'),
Text('Panoramica generale', style: TextStyle(fontSize: 12)),
],
),
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: _loadData,
),
],
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: _errorMessage != null
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.error_outline,
size: 64,
color: AppColors.critical,
),
const SizedBox(height: 16),
Text(_errorMessage!),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: _loadData,
icon: const Icon(Icons.refresh),
label: const Text('Riprova'),
),
],
),
)
: RefreshIndicator(
onRefresh: _loadData,
child: SingleChildScrollView(
padding: const EdgeInsets.all(AppSizes.paddingL),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// KPI Cards
_buildKPISection(),
const SizedBox(height: 24),
// Grafico Severità
_buildSeveritaChart(),
const SizedBox(height: 24),
// Grafico Stati
_buildStatiChart(),
const SizedBox(height: 24),
// Grafico Temporale
_buildTimelineChart(),
const SizedBox(height: 24),
// Grafico Siti per Tipo
_buildSitiPerTipoChart(),
],
),
),
),
);
}
Widget _buildKPISection() {
if (_statistiche == null) return const SizedBox.shrink();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Indicatori Chiave', style: AppTextStyles.h2),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: _buildKPICard(
'Allarmi Totali',
_statistiche!.totaleAllarmi.toString(),
Icons.notifications_active,
AppColors.primary,
),
),
const SizedBox(width: AppSizes.paddingM),
Expanded(
child: _buildKPICard(
'Siti Monitorati',
_statistiche!.totaleSiti.toString(),
Icons.location_on,
AppColors.secondary,
),
),
],
),
const SizedBox(height: AppSizes.paddingM),
Row(
children: [
Expanded(
child: _buildKPICard(
'Aperti',
_statistiche!.allarmiAperti.toString(),
Icons.warning,
AppColors.warning,
subtitle:
'${_statistiche!.percentualeAperti.toStringAsFixed(1)}%',
),
),
const SizedBox(width: AppSizes.paddingM),
Expanded(
child: _buildKPICard(
'Critici',
_statistiche!.allarmiCritical.toString(),
Icons.error,
AppColors.critical,
subtitle:
'${_statistiche!.percentualeCritici.toStringAsFixed(1)}%',
),
),
],
),
],
);
}
Widget _buildKPICard(
String label,
String value,
IconData icon,
Color color, {
String? subtitle,
}) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [color, color.withOpacity(0.7)],
),
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: color.withOpacity(0.3),
blurRadius: 8,
offset: const Offset(0, 4),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, color: Colors.white, size: 28),
const SizedBox(height: 12),
Text(
value,
style: const TextStyle(
color: Colors.white,
fontSize: 32,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
label,
style: TextStyle(
color: Colors.white.withOpacity(0.9),
fontSize: 14,
),
),
if (subtitle != null) ...[
const SizedBox(height: 4),
Text(
subtitle,
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
],
],
),
);
}
Widget _buildSeveritaChart() {
if (_statistiche == null) return const SizedBox.shrink();
final sections = [
if (_statistiche!.allarmiCritical > 0)
PieChartSectionData(
color: AppColors.critical,
value: _statistiche!.allarmiCritical.toDouble(),
title: '${_statistiche!.allarmiCritical}',
radius: 50,
titleStyle: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
if (_statistiche!.allarmiWarning > 0)
PieChartSectionData(
color: AppColors.warning,
value: _statistiche!.allarmiWarning.toDouble(),
title: '${_statistiche!.allarmiWarning}',
radius: 50,
titleStyle: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
if (_statistiche!.allarmiInfo > 0)
PieChartSectionData(
color: AppColors.info,
value: _statistiche!.allarmiInfo.toDouble(),
title: '${_statistiche!.allarmiInfo}',
radius: 50,
titleStyle: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
];
if (sections.isEmpty) {
return const SizedBox.shrink();
}
return _buildChartCard(
'Allarmi per Severità',
Column(
children: [
SizedBox(
height: 200,
child: PieChart(
PieChartData(
sections: sections,
sectionsSpace: 2,
centerSpaceRadius: 40,
),
),
),
const SizedBox(height: 16),
_buildLegend([
_LegendItem('Critici', AppColors.critical, _statistiche!.allarmiCritical),
_LegendItem('Avvisi', AppColors.warning, _statistiche!.allarmiWarning),
_LegendItem('Info', AppColors.info, _statistiche!.allarmiInfo),
]),
],
),
);
}
Widget _buildStatiChart() {
if (_statistiche == null) return const SizedBox.shrink();
return _buildChartCard(
'Allarmi per Stato',
Column(
children: [
SizedBox(
height: 200,
child: BarChart(
BarChartData(
alignment: BarChartAlignment.spaceAround,
maxY: _statistiche!.totaleStato.toDouble() * 1.2,
barTouchData: BarTouchData(enabled: false),
titlesData: FlTitlesData(
show: true,
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
getTitlesWidget: (value, meta) {
switch (value.toInt()) {
case 0:
return const Text('Nuovi', style: TextStyle(fontSize: 12));
case 1:
return const Text('In Gest.', style: TextStyle(fontSize: 12));
case 2:
return const Text('Risolti', style: TextStyle(fontSize: 12));
default:
return const Text('');
}
},
),
),
leftTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
topTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
rightTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
),
borderData: FlBorderData(show: false),
barGroups: [
BarChartGroupData(
x: 0,
barRods: [
BarChartRodData(
toY: _statistiche!.allarmiNuovo.toDouble(),
color: AppColors.critical,
width: 40,
borderRadius: const BorderRadius.vertical(top: Radius.circular(4)),
),
],
),
BarChartGroupData(
x: 1,
barRods: [
BarChartRodData(
toY: _statistiche!.allarmiInGestione.toDouble(),
color: AppColors.warning,
width: 40,
borderRadius: const BorderRadius.vertical(top: Radius.circular(4)),
),
],
),
BarChartGroupData(
x: 2,
barRods: [
BarChartRodData(
toY: _statistiche!.allarmiRisolto.toDouble(),
color: AppColors.success,
width: 40,
borderRadius: const BorderRadius.vertical(top: Radius.circular(4)),
),
],
),
],
),
),
),
],
),
);
}
Widget _buildTimelineChart() {
if (_allarmiPerGiorno == null || _allarmiPerGiorno!.dati.isEmpty) {
return const SizedBox.shrink();
}
final spots = _allarmiPerGiorno!.dati.asMap().entries.map((entry) {
return FlSpot(entry.key.toDouble(), entry.value.count.toDouble());
}).toList();
final maxY = _allarmiPerGiorno!.dati
.map((d) => d.count)
.reduce((a, b) => a > b ? a : b)
.toDouble();
return _buildChartCard(
'Andamento Temporale (30 giorni)',
SizedBox(
height: 200,
child: LineChart(
LineChartData(
gridData: FlGridData(
show: true,
drawVerticalLine: false,
horizontalInterval: 1,
getDrawingHorizontalLine: (value) {
return FlLine(
color: Colors.grey[300],
strokeWidth: 1,
);
},
),
titlesData: FlTitlesData(
show: true,
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 30,
interval: 5,
getTitlesWidget: (value, meta) {
if (value.toInt() >= _allarmiPerGiorno!.dati.length) {
return const Text('');
}
final data = _allarmiPerGiorno!.dati[value.toInt()].data;
return Text(
DateFormat('d/M').format(data),
style: const TextStyle(fontSize: 10),
);
},
),
),
leftTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
topTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
rightTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
),
borderData: FlBorderData(show: false),
minX: 0,
maxX: spots.length.toDouble() - 1,
minY: 0,
maxY: maxY * 1.2,
lineBarsData: [
LineChartBarData(
spots: spots,
isCurved: true,
color: AppColors.primary,
barWidth: 3,
isStrokeCapRound: true,
dotData: const FlDotData(show: false),
belowBarData: BarAreaData(
show: true,
color: AppColors.primary.withOpacity(0.1),
),
),
],
),
),
),
);
}
Widget _buildSitiPerTipoChart() {
if (_statistiche == null) return const SizedBox.shrink();
final data = [
_ChartData('Ponti', _statistiche!.sitiPonte, Colors.blue),
_ChartData('Gallerie', _statistiche!.sitiGalleria, Colors.brown),
_ChartData('Dighe', _statistiche!.sitiDiga, Colors.cyan),
_ChartData('Frane', _statistiche!.sitiFrana, Colors.orange),
_ChartData('Versanti', _statistiche!.sitiVersante, Colors.green),
_ChartData('Edifici', _statistiche!.sitiEdificio, Colors.purple),
].where((d) => d.value > 0).toList();
if (data.isEmpty) return const SizedBox.shrink();
return _buildChartCard(
'Siti per Tipologia',
SizedBox(
height: 200,
child: BarChart(
BarChartData(
alignment: BarChartAlignment.spaceAround,
maxY: data.map((d) => d.value).reduce((a, b) => a > b ? a : b).toDouble() * 1.2,
barTouchData: BarTouchData(enabled: false),
titlesData: FlTitlesData(
show: true,
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
getTitlesWidget: (value, meta) {
if (value.toInt() >= data.length) return const Text('');
return Text(
data[value.toInt()].label.substring(0, 3),
style: const TextStyle(fontSize: 12),
);
},
),
),
leftTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
topTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
rightTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
),
borderData: FlBorderData(show: false),
barGroups: data.asMap().entries.map((entry) {
return BarChartGroupData(
x: entry.key,
barRods: [
BarChartRodData(
toY: entry.value.value.toDouble(),
color: entry.value.color,
width: 30,
borderRadius: const BorderRadius.vertical(top: Radius.circular(4)),
),
],
);
}).toList(),
),
),
),
);
}
Widget _buildChartCard(String title, Widget child) {
return Container(
padding: const EdgeInsets.all(AppSizes.paddingL),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: AppTextStyles.h3),
const SizedBox(height: 16),
child,
],
),
);
}
Widget _buildLegend(List<_LegendItem> items) {
return Wrap(
spacing: 16,
runSpacing: 8,
children: items.map((item) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 16,
height: 16,
decoration: BoxDecoration(
color: item.color,
shape: BoxShape.circle,
),
),
const SizedBox(width: 8),
Text(
'${item.label}: ${item.value}',
style: AppTextStyles.bodySmall,
),
],
);
}).toList(),
);
}
}
class _LegendItem {
final String label;
final Color color;
final int value;
_LegendItem(this.label, this.color, this.value);
}
class _ChartData {
final String label;
final int value;
final Color color;
_ChartData(this.label, this.value, this.color);
}

View File

@@ -0,0 +1,226 @@
import 'package:flutter/material.dart';
import '../utils/constants.dart';
import '../models/allarme.dart';
import '../services/api_service.dart';
import '../widgets/allarme_card.dart';
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
final _apiService = ApiService();
List<Allarme> _allarmi = [];
List<Allarme> _allarmiFiltered = [];
bool _isLoading = true;
String? _errorMessage;
String? _selectedSeverita;
String _searchQuery = '';
String _sortBy = 'data_desc'; // data_desc, data_asc, severita
@override
void initState() {
super.initState();
_loadAllarmi();
}
Future<void> _loadAllarmi({bool refresh = false}) async {
if (refresh) setState(() => _isLoading = true);
try {
final response = await _apiService.getAllarmi(severita: _selectedSeverita);
setState(() {
_allarmi = response.items;
_applyFiltersAndSort();
_isLoading = false;
});
} catch (e) {
setState(() {
_errorMessage = 'Errore caricamento allarmi';
_isLoading = false;
});
}
}
void _applyFiltersAndSort() {
// Filtra per ricerca
var filtered = _allarmi.where((allarme) {
if (_searchQuery.isEmpty) return true;
final query = _searchQuery.toLowerCase();
return allarme.titolo.toLowerCase().contains(query) ||
allarme.descrizione?.toLowerCase().contains(query) == true ||
allarme.tipoReadable.toLowerCase().contains(query);
}).toList();
// Ordina
switch (_sortBy) {
case 'data_desc':
filtered.sort((a, b) => b.timestampRilevamento.compareTo(a.timestampRilevamento));
break;
case 'data_asc':
filtered.sort((a, b) => a.timestampRilevamento.compareTo(b.timestampRilevamento));
break;
case 'severita':
filtered.sort((a, b) {
final severityOrder = {'critical': 0, 'warning': 1, 'info': 2};
return (severityOrder[a.severita] ?? 3).compareTo(severityOrder[b.severita] ?? 3);
});
break;
}
_allarmiFiltered = filtered;
}
void _showFilterDialog() {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Filtri'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
title: const Text('Tutti'),
leading: Radio<String?>(value: null, groupValue: _selectedSeverita, onChanged: (val) { Navigator.pop(context); setState(() => _selectedSeverita = val); _loadAllarmi(refresh: true); }),
),
ListTile(
title: const Text('CRITICO'),
leading: Radio<String?>(value: 'critical', groupValue: _selectedSeverita, onChanged: (val) { Navigator.pop(context); setState(() => _selectedSeverita = val); _loadAllarmi(refresh: true); }),
),
ListTile(
title: const Text('AVVISO'),
leading: Radio<String?>(value: 'warning', groupValue: _selectedSeverita, onChanged: (val) { Navigator.pop(context); setState(() => _selectedSeverita = val); _loadAllarmi(refresh: true); }),
),
],
),
),
);
}
void _showSortDialog() {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Ordina per'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildSortOption('data_desc', 'Più recenti', Icons.arrow_downward),
_buildSortOption('data_asc', 'Meno recenti', Icons.arrow_upward),
_buildSortOption('severita', 'Severità', Icons.warning),
],
),
),
);
}
Widget _buildSortOption(String value, String label, IconData icon) {
final isSelected = _sortBy == value;
return ListTile(
leading: Icon(
icon,
color: isSelected ? AppColors.primary : AppColors.textSecondary,
),
title: Text(
label,
style: TextStyle(
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
color: isSelected ? AppColors.primary : null,
),
),
trailing: isSelected ? const Icon(Icons.check, color: AppColors.primary) : null,
onTap: () {
Navigator.pop(context);
setState(() {
_sortBy = value;
_applyFiltersAndSort();
});
},
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Column(
children: [Text('ASE Monitor'), Text('Allarmi Attivi', style: TextStyle(fontSize: 12))],
),
actions: [
IconButton(icon: const Icon(Icons.sort), onPressed: _showSortDialog),
IconButton(icon: const Icon(Icons.filter_list), onPressed: _showFilterDialog),
],
),
body: Column(
children: [
// Search bar
Padding(
padding: const EdgeInsets.all(AppSizes.paddingM),
child: TextField(
decoration: InputDecoration(
hintText: 'Cerca allarmi...',
prefixIcon: const Icon(Icons.search),
suffixIcon: _searchQuery.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
setState(() {
_searchQuery = '';
_applyFiltersAndSort();
});
},
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
filled: true,
fillColor: Colors.grey[100],
),
onChanged: (value) {
setState(() {
_searchQuery = value;
_applyFiltersAndSort();
});
},
),
),
// Results count
if (_searchQuery.isNotEmpty)
Padding(
padding: const EdgeInsets.symmetric(horizontal: AppSizes.paddingM),
child: Text(
'${_allarmiFiltered.length} risultati trovati',
style: AppTextStyles.bodySmall.copyWith(color: AppColors.textSecondary),
),
),
// List
Expanded(
child: _isLoading
? const Center(child: CircularProgressIndicator())
: _errorMessage != null
? Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [const Icon(Icons.error_outline, size: 64, color: AppColors.critical), Text(_errorMessage!)]))
: _allarmiFiltered.isEmpty
? const Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [Icon(Icons.check_circle_outline, size: 64, color: AppColors.success), SizedBox(height: 16), Text('Nessun allarme', style: AppTextStyles.h3)]))
: RefreshIndicator(
onRefresh: () => _loadAllarmi(refresh: true),
child: ListView.builder(
padding: const EdgeInsets.all(AppSizes.paddingM),
itemCount: _allarmiFiltered.length,
itemBuilder: (context, index) => AllarmeCard(allarme: _allarmiFiltered[index]),
),
),
),
],
),
floatingActionButton: FloatingActionButton(
onPressed: () => _loadAllarmi(refresh: true),
child: const Icon(Icons.refresh),
),
);
}
}

View File

@@ -0,0 +1,224 @@
import 'package:flutter/material.dart';
import '../utils/constants.dart';
import '../services/auth_service.dart';
import '../services/notification_service.dart';
import 'main_screen.dart';
class LoginScreen extends StatefulWidget {
const LoginScreen({super.key});
@override
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
final _authService = AuthService();
bool _isLoading = false;
bool _obscurePassword = true;
String? _errorMessage;
@override
void dispose() {
_emailController.dispose();
_passwordController.dispose();
super.dispose();
}
Future<void> _handleLogin() async {
if (!_formKey.currentState!.validate()) return;
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
final success = await _authService.login(
_emailController.text.trim(),
_passwordController.text,
);
if (success && mounted) {
// Registra FCM token dopo login riuscito
try {
final fcmToken = await NotificationService().getFcmToken();
if (fcmToken != null) {
await _authService.saveFcmToken(fcmToken);
print('✓ FCM token registrato dopo login');
}
} catch (e) {
print('⚠️ Errore registrazione FCM token: $e');
// Non blocchiamo il login se la registrazione token fallisce
}
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (context) => const MainScreen()),
);
} else {
setState(() {
_errorMessage = 'Email o password non corretti';
});
}
} catch (e) {
setState(() {
_errorMessage = 'Errore durante il login';
});
} finally {
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Container(
decoration: const BoxDecoration(
gradient: AppColors.primaryGradient,
),
child: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(AppSizes.paddingL),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildHeader(),
const SizedBox(height: 60),
_buildLoginCard(),
const SizedBox(height: AppSizes.paddingL),
_buildFooter(),
],
),
),
),
),
),
);
}
Widget _buildHeader() {
return Column(
children: [
Container(
width: 120,
height: 120,
decoration: BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.2),
blurRadius: 20,
offset: const Offset(0, 10),
),
],
),
child: const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.terrain, size: 50, color: AppColors.primary),
SizedBox(height: 4),
Text('ASE', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: AppColors.primary)),
],
),
),
),
const SizedBox(height: AppSizes.paddingL),
const Text('ASE Monitor', style: TextStyle(fontSize: 32, fontWeight: FontWeight.bold, color: Colors.white)),
const SizedBox(height: AppSizes.paddingS),
Text('Advanced Slope Engineering', style: TextStyle(fontSize: 16, color: Colors.white.withOpacity(0.9))),
],
);
}
Widget _buildLoginCard() {
return Card(
elevation: 8,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppSizes.radiusL)),
child: Padding(
padding: const EdgeInsets.all(AppSizes.paddingL),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text('Accedi', style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), textAlign: TextAlign.center),
const SizedBox(height: AppSizes.paddingL),
TextFormField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(labelText: 'Email', hintText: 'nome@azienda.it', prefixIcon: Icon(Icons.email_outlined)),
validator: (value) {
if (value == null || value.isEmpty) return 'Inserisci email';
if (!value.contains('@')) return 'Email non valida';
return null;
},
),
const SizedBox(height: AppSizes.paddingM),
TextFormField(
controller: _passwordController,
obscureText: _obscurePassword,
decoration: InputDecoration(
labelText: 'Password',
prefixIcon: const Icon(Icons.lock_outline),
suffixIcon: IconButton(
icon: Icon(_obscurePassword ? Icons.visibility_outlined : Icons.visibility_off_outlined),
onPressed: () => setState(() => _obscurePassword = !_obscurePassword),
),
),
validator: (value) => (value == null || value.isEmpty) ? 'Inserisci password' : null,
onFieldSubmitted: (_) => _handleLogin(),
),
if (_errorMessage != null) ...[
const SizedBox(height: AppSizes.paddingM),
Container(
padding: const EdgeInsets.all(AppSizes.paddingM),
decoration: BoxDecoration(
color: AppColors.critical.withOpacity(0.1),
borderRadius: BorderRadius.circular(AppSizes.radiusS),
),
child: Row(
children: [
const Icon(Icons.error_outline, color: AppColors.critical, size: 20),
const SizedBox(width: AppSizes.paddingS),
Expanded(child: Text(_errorMessage!, style: const TextStyle(color: AppColors.critical))),
],
),
),
],
const SizedBox(height: AppSizes.paddingL),
SizedBox(
height: AppSizes.buttonHeight,
child: ElevatedButton(
onPressed: _isLoading ? null : _handleLogin,
child: _isLoading
? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2, valueColor: AlwaysStoppedAnimation(Colors.white)))
: const Row(mainAxisAlignment: MainAxisAlignment.center, children: [Icon(Icons.login), SizedBox(width: 8), Text('Accedi')]),
),
),
],
),
),
),
);
}
Widget _buildFooter() {
return Column(
children: [
Text('Versione ${AppConstants.appVersion}', style: TextStyle(color: Colors.white.withOpacity(0.7), fontSize: 12)),
const SizedBox(height: 4),
Text('© 2025 ${AppConstants.companyName}', style: TextStyle(color: Colors.white.withOpacity(0.7), fontSize: 12), textAlign: TextAlign.center),
],
);
}
}

View File

@@ -0,0 +1,55 @@
import 'package:flutter/material.dart';
import '../utils/constants.dart';
import 'dashboard_screen.dart';
import 'home_screen.dart';
import 'siti_screen.dart';
import 'profile_screen.dart';
class MainScreen extends StatefulWidget {
const MainScreen({super.key});
@override
State<MainScreen> createState() => _MainScreenState();
}
class _MainScreenState extends State<MainScreen> {
int _currentIndex = 0;
final List<Widget> _screens = [
const DashboardScreen(),
const HomeScreen(),
const SitiScreen(),
const ProfileScreen(),
];
@override
Widget build(BuildContext context) {
return Scaffold(
body: _screens[_currentIndex],
bottomNavigationBar: BottomNavigationBar(
currentIndex: _currentIndex,
onTap: (index) => setState(() => _currentIndex = index),
selectedItemColor: AppColors.primary,
unselectedItemColor: AppColors.textSecondary,
items: const [
BottomNavigationBarItem(
icon: Icon(Icons.dashboard),
label: 'Dashboard',
),
BottomNavigationBarItem(
icon: Icon(Icons.notifications_active),
label: 'Allarmi',
),
BottomNavigationBarItem(
icon: Icon(Icons.location_on),
label: 'Siti',
),
BottomNavigationBarItem(
icon: Icon(Icons.person),
label: 'Profilo',
),
],
),
);
}
}

View File

@@ -0,0 +1,97 @@
import 'package:flutter/material.dart';
import '../utils/constants.dart';
import '../services/auth_service.dart';
import 'login_screen.dart';
class ProfileScreen extends StatelessWidget {
const ProfileScreen({super.key});
Future<void> _handleLogout(BuildContext context) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Conferma Logout'),
content: const Text('Sei sicuro di voler uscire?'),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Annulla')),
ElevatedButton(
onPressed: () => Navigator.pop(context, true),
style: ElevatedButton.styleFrom(backgroundColor: AppColors.critical),
child: const Text('Esci'),
),
],
),
);
if (confirmed == true && context.mounted) {
await AuthService().logout();
if (context.mounted) {
Navigator.of(context).pushAndRemoveUntil(MaterialPageRoute(builder: (_) => const LoginScreen()), (_) => false);
}
}
}
@override
Widget build(BuildContext context) {
final user = AuthService().currentUser;
return Scaffold(
appBar: AppBar(title: const Text('Profilo')),
body: user == null
? const Center(child: CircularProgressIndicator())
: SingleChildScrollView(
padding: const EdgeInsets.all(AppSizes.paddingM),
child: Column(
children: [
Card(
child: Padding(
padding: const EdgeInsets.all(AppSizes.paddingL),
child: Column(
children: [
CircleAvatar(
radius: 50,
backgroundColor: AppColors.primary,
child: Text('${user.nome[0]}${user.cognome[0]}'.toUpperCase(), style: const TextStyle(fontSize: 32, fontWeight: FontWeight.bold, color: Colors.white)),
),
const SizedBox(height: AppSizes.paddingM),
Text(user.nomeCompleto, style: AppTextStyles.h2, textAlign: TextAlign.center),
Text(user.email, style: AppTextStyles.bodyMedium.copyWith(color: AppColors.textSecondary)),
const SizedBox(height: AppSizes.paddingM),
Container(
padding: const EdgeInsets.symmetric(horizontal: AppSizes.paddingM, vertical: AppSizes.paddingS),
decoration: BoxDecoration(
color: AppColors.primary.withOpacity(0.1),
borderRadius: BorderRadius.circular(AppSizes.radiusL),
),
child: Text(user.ruolo.toUpperCase(), style: const TextStyle(color: AppColors.primary, fontWeight: FontWeight.w600)),
),
],
),
),
),
const SizedBox(height: AppSizes.paddingM),
Card(
child: Column(
children: [
ListTile(leading: const Icon(Icons.business_outlined), title: const Text('ID Cliente'), trailing: Text(user.clienteId.toString())),
ListTile(leading: const Icon(Icons.verified_outlined), title: const Text('Versione'), trailing: Text(AppConstants.appVersion)),
],
),
),
const SizedBox(height: AppSizes.paddingM),
SizedBox(
width: double.infinity,
height: AppSizes.buttonHeight,
child: ElevatedButton.icon(
onPressed: () => _handleLogout(context),
icon: const Icon(Icons.logout),
label: const Text('Esci'),
style: ElevatedButton.styleFrom(backgroundColor: AppColors.critical),
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,221 @@
import 'package:flutter/material.dart';
import '../models/sito.dart';
import '../services/api_service.dart';
import '../widgets/sito_card.dart';
import '../utils/constants.dart';
class SitiScreen extends StatefulWidget {
const SitiScreen({super.key});
@override
State<SitiScreen> createState() => _SitiScreenState();
}
class _SitiScreenState extends State<SitiScreen> {
final _apiService = ApiService();
List<Sito> _siti = [];
List<Sito> _sitiFiltered = [];
bool _isLoading = true;
String? _errorMessage;
String? _selectedTipo;
final Map<String, String> _tipiSito = {
'ponte': 'Ponte',
'galleria': 'Galleria',
'diga': 'Diga',
'frana': 'Frana',
'versante': 'Versante',
'edificio': 'Edificio',
};
@override
void initState() {
super.initState();
_loadSiti();
}
Future<void> _loadSiti({bool refresh = false}) async {
if (refresh) setState(() => _isLoading = true);
try {
final siti = await _apiService.getSiti();
setState(() {
_siti = siti;
_applyFilter();
_isLoading = false;
_errorMessage = null;
});
} catch (e) {
setState(() {
_errorMessage = 'Errore caricamento siti: $e';
_isLoading = false;
});
}
}
void _applyFilter() {
if (_selectedTipo == null) {
_sitiFiltered = _siti;
} else {
_sitiFiltered = _siti.where((s) => s.tipo == _selectedTipo).toList();
}
}
void _showFilterDialog() {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Filtra per Tipo'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildFilterOption(null, 'Tutti i siti', Icons.all_inclusive),
const Divider(),
..._tipiSito.entries.map((entry) {
return _buildFilterOption(
entry.key,
entry.value,
_getIconForTipo(entry.key),
);
}),
],
),
),
),
);
}
Widget _buildFilterOption(String? tipo, String label, IconData icon) {
final isSelected = _selectedTipo == tipo;
return ListTile(
leading: Icon(
icon,
color: isSelected ? AppColors.primary : AppColors.textSecondary,
),
title: Text(
label,
style: TextStyle(
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
color: isSelected ? AppColors.primary : null,
),
),
trailing: isSelected
? const Icon(Icons.check, color: AppColors.primary)
: null,
onTap: () {
Navigator.pop(context);
setState(() {
_selectedTipo = tipo;
_applyFilter();
});
},
);
}
IconData _getIconForTipo(String tipo) {
switch (tipo) {
case 'ponte':
return Icons.architecture;
case 'galleria':
return Icons.south_west;
case 'diga':
return Icons.water_damage;
case 'frana':
return Icons.landscape;
case 'versante':
return Icons.terrain;
case 'edificio':
return Icons.business;
default:
return Icons.location_on;
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Siti Monitorati'),
Text(
_selectedTipo != null
? _tipiSito[_selectedTipo]!
: '${_siti.length} siti totali',
style: const TextStyle(fontSize: 12),
),
],
),
actions: [
IconButton(
icon: const Icon(Icons.filter_list),
onPressed: _showFilterDialog,
tooltip: 'Filtra per tipo',
),
],
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: _errorMessage != null
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.error_outline,
size: 64,
color: AppColors.critical,
),
const SizedBox(height: 16),
Text(_errorMessage!),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: () => _loadSiti(refresh: true),
icon: const Icon(Icons.refresh),
label: const Text('Riprova'),
),
],
),
)
: _sitiFiltered.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.location_off,
size: 64,
color: AppColors.textSecondary,
),
const SizedBox(height: 16),
Text(
_selectedTipo != null
? 'Nessun sito di tipo ${_tipiSito[_selectedTipo]}'
: 'Nessun sito disponibile',
style: AppTextStyles.h3,
),
],
),
)
: RefreshIndicator(
onRefresh: () => _loadSiti(refresh: true),
child: ListView.builder(
padding: const EdgeInsets.all(AppSizes.paddingM),
itemCount: _sitiFiltered.length,
itemBuilder: (context, index) {
return SitoCard(sito: _sitiFiltered[index]);
},
),
),
floatingActionButton: _isLoading
? null
: FloatingActionButton(
onPressed: () => _loadSiti(refresh: true),
child: const Icon(Icons.refresh),
),
);
}
}

View File

@@ -0,0 +1,494 @@
import 'package:flutter/material.dart';
import 'package:google_maps_flutter/google_maps_flutter.dart';
import 'package:intl/intl.dart';
import '../models/sito.dart';
import '../models/allarme.dart';
import '../services/api_service.dart';
import '../widgets/allarme_card.dart';
import '../utils/constants.dart';
class SitoDetailScreen extends StatefulWidget {
final Sito sito;
const SitoDetailScreen({super.key, required this.sito});
@override
State<SitoDetailScreen> createState() => _SitoDetailScreenState();
}
class _SitoDetailScreenState extends State<SitoDetailScreen>
with SingleTickerProviderStateMixin {
final _apiService = ApiService();
List<Allarme> _allarmi = [];
bool _isLoadingAllarmi = true;
String? _errorMessage;
late TabController _tabController;
GoogleMapController? _mapController;
@override
void initState() {
super.initState();
_tabController = TabController(length: 2, vsync: this);
_loadAllarmi();
}
@override
void dispose() {
_tabController.dispose();
_mapController?.dispose();
super.dispose();
}
Future<void> _loadAllarmi() async {
try {
final response = await _apiService.getAllarmiBySito(widget.sito.id);
setState(() {
_allarmi = response.items;
_isLoadingAllarmi = false;
});
} catch (e) {
setState(() {
_errorMessage = 'Errore caricamento allarmi: $e';
_isLoadingAllarmi = false;
});
}
}
Color _getTipoColor() {
switch (widget.sito.tipo) {
case 'ponte':
return Colors.blue;
case 'galleria':
return Colors.brown;
case 'diga':
return Colors.cyan;
case 'frana':
return Colors.orange;
case 'versante':
return Colors.green;
case 'edificio':
return Colors.purple;
default:
return AppColors.primary;
}
}
IconData _getTipoIcon() {
switch (widget.sito.tipo) {
case 'ponte':
return Icons.architecture;
case 'galleria':
return Icons.south_west;
case 'diga':
return Icons.water_damage;
case 'frana':
return Icons.landscape;
case 'versante':
return Icons.terrain;
case 'edificio':
return Icons.business;
default:
return Icons.location_on;
}
}
int get _allarmiCritici =>
_allarmi.where((a) => a.severita == 'critical').length;
int get _allarmiAperti =>
_allarmi.where((a) => a.stato != 'risolto').length;
@override
Widget build(BuildContext context) {
final dateFormat = DateFormat('dd/MM/yyyy');
return Scaffold(
body: NestedScrollView(
headerSliverBuilder: (context, innerBoxIsScrolled) {
return [
SliverAppBar(
expandedHeight: 180,
pinned: true,
flexibleSpace: FlexibleSpaceBar(
background: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
_getTipoColor(),
_getTipoColor().withOpacity(0.7),
],
),
),
child: SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(
AppSizes.paddingL,
AppSizes.paddingL,
AppSizes.paddingL,
70, // Spazio per le tab (altezza standard tab bar)
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.end,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
),
child: Icon(
_getTipoIcon(),
size: 32,
color: _getTipoColor(),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.sito.tipoReadable,
style: TextStyle(
color: Colors.white.withOpacity(0.9),
fontSize: 14,
),
),
Text(
widget.sito.nome,
style: const TextStyle(
color: Colors.white,
fontSize: 24,
fontWeight: FontWeight.bold,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
],
),
),
),
),
),
bottom: TabBar(
controller: _tabController,
tabs: const [
Tab(text: 'Informazioni', icon: Icon(Icons.info_outline)),
Tab(text: 'Allarmi', icon: Icon(Icons.notifications_active)),
],
),
),
];
},
body: TabBarView(
controller: _tabController,
children: [
// Tab Informazioni
_buildInfoTab(dateFormat),
// Tab Allarmi
_buildAllarmiTab(),
],
),
),
);
}
Widget _buildInfoTab(DateFormat dateFormat) {
return SingleChildScrollView(
padding: const EdgeInsets.all(AppSizes.paddingL),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Statistiche rapide
Row(
children: [
Expanded(
child: _buildStatCard(
'Allarmi Totali',
_allarmi.length.toString(),
Icons.notifications,
AppColors.primary,
),
),
const SizedBox(width: AppSizes.paddingM),
Expanded(
child: _buildStatCard(
'Critici',
_allarmiCritici.toString(),
Icons.warning,
AppColors.critical,
),
),
const SizedBox(width: AppSizes.paddingM),
Expanded(
child: _buildStatCard(
'Aperti',
_allarmiAperti.toString(),
Icons.pending,
AppColors.warning,
),
),
],
),
const SizedBox(height: 24),
// Descrizione
if (widget.sito.descrizione != null) ...[
const Text('Descrizione', style: AppTextStyles.h3),
const SizedBox(height: 8),
Text(
widget.sito.descrizione!,
style: AppTextStyles.bodyLarge,
),
const SizedBox(height: 24),
],
// Mappa
if (widget.sito.hasCoordinates) ...[
const Text('Posizione', style: AppTextStyles.h3),
const SizedBox(height: 12),
_buildMapView(),
const SizedBox(height: 24),
],
// Dettagli tecnici
const Text('Dettagli', style: AppTextStyles.h3),
const SizedBox(height: 12),
_buildInfoCard(
icon: Icons.place,
title: 'Località',
value: widget.sito.localita,
),
if (widget.sito.regione != null) ...[
const SizedBox(height: 8),
_buildInfoCard(
icon: Icons.map,
title: 'Regione',
value: widget.sito.regione!,
),
],
if (widget.sito.codiceIdentificativo != null) ...[
const SizedBox(height: 8),
_buildInfoCard(
icon: Icons.qr_code,
title: 'Codice Identificativo',
value: widget.sito.codiceIdentificativo!,
),
],
if (widget.sito.hasCoordinates) ...[
const SizedBox(height: 8),
_buildInfoCard(
icon: Icons.my_location,
title: 'Coordinate',
value:
'${widget.sito.latitudine!.toStringAsFixed(6)}, ${widget.sito.longitudine!.toStringAsFixed(6)}',
),
],
if (widget.sito.altitudine != null) ...[
const SizedBox(height: 8),
_buildInfoCard(
icon: Icons.height,
title: 'Altitudine',
value: '${widget.sito.altitudine!.toStringAsFixed(0)} m s.l.m.',
),
],
const SizedBox(height: 8),
_buildInfoCard(
icon: Icons.calendar_today,
title: 'Data creazione',
value: dateFormat.format(widget.sito.createdAt),
),
],
),
);
}
Widget _buildAllarmiTab() {
if (_isLoadingAllarmi) {
return const Center(child: CircularProgressIndicator());
}
if (_errorMessage != null) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, size: 64, color: AppColors.critical),
const SizedBox(height: 16),
Text(_errorMessage!),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: _loadAllarmi,
icon: const Icon(Icons.refresh),
label: const Text('Riprova'),
),
],
),
);
}
if (_allarmi.isEmpty) {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.check_circle_outline, size: 64, color: AppColors.success),
SizedBox(height: 16),
Text('Nessun allarme registrato', style: AppTextStyles.h3),
SizedBox(height: 8),
Text(
'Questo sito non ha allarmi nello storico',
style: AppTextStyles.bodyMedium,
textAlign: TextAlign.center,
),
],
),
);
}
return RefreshIndicator(
onRefresh: _loadAllarmi,
child: ListView.builder(
padding: const EdgeInsets.all(AppSizes.paddingM),
itemCount: _allarmi.length,
itemBuilder: (context, index) {
return AllarmeCard(allarme: _allarmi[index]);
},
),
);
}
Widget _buildStatCard(String label, String value, IconData icon, Color color) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: color.withOpacity(0.3)),
),
child: Column(
children: [
Icon(icon, color: color, size: 24),
const SizedBox(height: 8),
Text(
value,
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: color,
),
),
const SizedBox(height: 4),
Text(
label,
style: TextStyle(
fontSize: 11,
color: color,
),
textAlign: TextAlign.center,
),
],
),
);
}
Widget _buildInfoCard({
required IconData icon,
required String title,
required String value,
}) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey[100],
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey[300]!),
),
child: Row(
children: [
Icon(icon, color: _getTipoColor(), size: 20),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: AppTextStyles.bodySmall.copyWith(
color: AppColors.textSecondary,
),
),
const SizedBox(height: 2),
Text(
value,
style: AppTextStyles.bodyMedium.copyWith(
fontWeight: FontWeight.w600,
),
),
],
),
),
],
),
);
}
Widget _buildMapView() {
if (!widget.sito.hasCoordinates) {
return const SizedBox.shrink();
}
final position =
LatLng(widget.sito.latitudine!, widget.sito.longitudine!);
return Container(
height: 250,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey[300]!),
),
clipBehavior: Clip.hardEdge,
child: GoogleMap(
initialCameraPosition: CameraPosition(
target: position,
zoom: 14,
),
markers: {
Marker(
markerId: MarkerId('sito_${widget.sito.id}'),
position: position,
infoWindow: InfoWindow(
title: widget.sito.nome,
snippet: widget.sito.tipoReadable,
),
icon: BitmapDescriptor.defaultMarkerWithHue(
_getTipoColor() == Colors.blue
? BitmapDescriptor.hueBlue
: _getTipoColor() == Colors.green
? BitmapDescriptor.hueGreen
: _getTipoColor() == Colors.orange
? BitmapDescriptor.hueOrange
: BitmapDescriptor.hueRed,
),
),
},
myLocationButtonEnabled: true,
zoomControlsEnabled: true,
mapToolbarEnabled: false,
onMapCreated: (controller) {
_mapController = controller;
},
),
);
}
}

View File

@@ -0,0 +1,166 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../utils/constants.dart';
import '../models/user.dart';
import '../models/allarme.dart';
import '../models/sito.dart';
import '../models/statistiche.dart';
class ApiService {
static final ApiService _instance = ApiService._internal();
factory ApiService() => _instance;
ApiService._internal();
String? _authToken;
void setAuthToken(String token) => _authToken = token;
void clearAuthToken() => _authToken = null;
Map<String, String> _getHeaders({bool includeAuth = true}) {
final headers = {
'Content-Type': 'application/json',
'Accept': 'application/json',
};
if (includeAuth && _authToken != null) {
headers['Authorization'] = 'Bearer $_authToken';
}
return headers;
}
Future<Map<String, dynamic>> _handleResponse(http.Response response) async {
if (response.statusCode >= 200 && response.statusCode < 300) {
return json.decode(utf8.decode(response.bodyBytes));
} else {
final error = json.decode(utf8.decode(response.bodyBytes));
throw Exception(error['detail'] ?? 'Errore sconosciuto');
}
}
Future<Map<String, dynamic>> login(String email, String password) async {
final response = await http.post(
Uri.parse('${AppConstants.apiBaseUrl}/auth/login'),
headers: _getHeaders(includeAuth: false),
body: json.encode({'email': email, 'password': password}),
);
return _handleResponse(response);
}
Future<User> getCurrentUser() async {
final response = await http.get(
Uri.parse('${AppConstants.apiBaseUrl}/auth/me'),
headers: _getHeaders(),
);
final data = await _handleResponse(response);
return User.fromJson(data);
}
Future<void> registerFcmToken(String fcmToken) async {
final response = await http.post(
Uri.parse('${AppConstants.apiBaseUrl}/auth/register-fcm-token'),
headers: _getHeaders(),
body: json.encode({'fcm_token': fcmToken}),
);
await _handleResponse(response);
}
Future<AllarmeListResponse> getAllarmi({
int page = 1,
int pageSize = 20,
String? severita,
String? stato,
}) async {
final queryParams = {
'page': page.toString(),
'page_size': pageSize.toString(),
if (severita != null) 'severita': severita,
if (stato != null) 'stato': stato,
};
final uri = Uri.parse('${AppConstants.apiBaseUrl}/allarmi')
.replace(queryParameters: queryParams);
final response = await http.get(uri, headers: _getHeaders());
final data = await _handleResponse(response);
return AllarmeListResponse.fromJson(data);
}
Future<Allarme> getAllarme(int id) async {
final response = await http.get(
Uri.parse('${AppConstants.apiBaseUrl}/allarmi/$id'),
headers: _getHeaders(),
);
final data = await _handleResponse(response);
return Allarme.fromJson(data);
}
Future<Allarme> updateAllarme(int id, {String? stato}) async {
final body = <String, dynamic>{};
if (stato != null) body['stato'] = stato;
final response = await http.patch(
Uri.parse('${AppConstants.apiBaseUrl}/allarmi/$id'),
headers: _getHeaders(),
body: json.encode(body),
);
final data = await _handleResponse(response);
return Allarme.fromJson(data);
}
Future<Sito> getSito(int sitoId) async {
final response = await http.get(
Uri.parse('${AppConstants.apiBaseUrl}/siti/$sitoId'),
headers: _getHeaders(),
);
final data = await _handleResponse(response);
return Sito.fromJson(data);
}
Future<List<Sito>> getSiti({String? tipo}) async {
final queryParams = <String, String>{};
if (tipo != null) queryParams['tipo'] = tipo;
final uri = Uri.parse('${AppConstants.apiBaseUrl}/siti')
.replace(queryParameters: queryParams.isNotEmpty ? queryParams : null);
final response = await http.get(uri, headers: _getHeaders());
final data = await _handleResponse(response);
final items = (data['items'] as List)
.map((item) => Sito.fromJson(item as Map<String, dynamic>))
.toList();
return items;
}
Future<AllarmeListResponse> getAllarmiBySito(int sitoId, {int page = 1, int pageSize = 20}) async {
final queryParams = {
'page': page.toString(),
'page_size': pageSize.toString(),
};
final uri = Uri.parse('${AppConstants.apiBaseUrl}/allarmi/sito/$sitoId')
.replace(queryParameters: queryParams);
final response = await http.get(uri, headers: _getHeaders());
final data = await _handleResponse(response);
return AllarmeListResponse.fromJson(data);
}
Future<Statistiche> getStatistiche() async {
final response = await http.get(
Uri.parse('${AppConstants.apiBaseUrl}/statistiche'),
headers: _getHeaders(),
);
final data = await _handleResponse(response);
return Statistiche.fromJson(data);
}
Future<AllarmiPerGiornoResponse> getAllarmiPerGiorno({int giorni = 30}) async {
final uri = Uri.parse('${AppConstants.apiBaseUrl}/statistiche/allarmi-per-giorno')
.replace(queryParameters: {'giorni': giorni.toString()});
final response = await http.get(uri, headers: _getHeaders());
final data = await _handleResponse(response);
return AllarmiPerGiornoResponse.fromJson(data);
}
}

View File

@@ -0,0 +1,76 @@
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'dart:convert';
import '../models/user.dart';
import '../utils/constants.dart';
import 'api_service.dart';
class AuthService {
static final AuthService _instance = AuthService._internal();
factory AuthService() => _instance;
AuthService._internal();
final _storage = const FlutterSecureStorage();
final _apiService = ApiService();
User? _currentUser;
User? get currentUser => _currentUser;
bool get isAuthenticated => _currentUser != null;
Future<bool> login(String email, String password) async {
try {
final response = await _apiService.login(email, password);
final token = response['access_token'] as String;
await _storage.write(key: AppConstants.storageKeyToken, value: token);
_apiService.setAuthToken(token);
_currentUser = await _apiService.getCurrentUser();
final prefs = await SharedPreferences.getInstance();
await prefs.setString(
AppConstants.storageKeyUser,
json.encode(_currentUser!.toJson()),
);
return true;
} catch (e) {
print('Login error: $e');
return false;
}
}
Future<bool> autoLogin() async {
try {
final token = await _storage.read(key: AppConstants.storageKeyToken);
if (token == null) return false;
_apiService.setAuthToken(token);
_currentUser = await _apiService.getCurrentUser();
return true;
} catch (e) {
print('Auto login error: $e');
await logout();
return false;
}
}
Future<void> logout() async {
_currentUser = null;
_apiService.clearAuthToken();
await _storage.delete(key: AppConstants.storageKeyToken);
final prefs = await SharedPreferences.getInstance();
await prefs.remove(AppConstants.storageKeyUser);
await prefs.remove(AppConstants.storageKeyFcmToken);
}
Future<void> saveFcmToken(String fcmToken) async {
try {
await _apiService.registerFcmToken(fcmToken);
final prefs = await SharedPreferences.getInstance();
await prefs.setString(AppConstants.storageKeyFcmToken, fcmToken);
} catch (e) {
print('Error saving FCM token: $e');
}
}
}

View File

@@ -0,0 +1,186 @@
import 'dart:async';
import 'dart:convert';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'auth_service.dart';
@pragma('vm:entry-point')
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
await Firebase.initializeApp();
print('Background message: ${message.messageId}');
}
class NotificationService {
static final NotificationService _instance = NotificationService._internal();
factory NotificationService() => _instance;
NotificationService._internal();
final FirebaseMessaging _firebaseMessaging = FirebaseMessaging.instance;
final FlutterLocalNotificationsPlugin _localNotifications =
FlutterLocalNotificationsPlugin();
bool _initialized = false;
// Stream per gestire i tap sulle notifiche
final StreamController<RemoteMessage> _messageStreamController =
StreamController<RemoteMessage>.broadcast();
Stream<RemoteMessage> get onMessageTap => _messageStreamController.stream;
Future<void> initialize() async {
if (_initialized) return;
try {
await _requestPermissions();
await _initializeLocalNotifications();
_configureHandlers();
await _getFcmToken();
_initialized = true;
print('NotificationService initialized');
} catch (e) {
print('Error initializing notifications: $e');
}
}
Future<void> _requestPermissions() async {
final settings = await _firebaseMessaging.requestPermission(
alert: true,
badge: true,
sound: true,
);
print('Notification permission: ${settings.authorizationStatus}');
}
Future<void> _initializeLocalNotifications() async {
const androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher');
const iosSettings = DarwinInitializationSettings();
await _localNotifications.initialize(
const InitializationSettings(android: androidSettings, iOS: iosSettings),
onDidReceiveNotificationResponse: _onNotificationTapped,
);
const channel = AndroidNotificationChannel(
'allarmi_channel',
'Allarmi',
description: 'Notifiche per allarmi del sistema di monitoraggio',
importance: Importance.high,
playSound: true,
enableVibration: true,
);
await _localNotifications
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>()
?.createNotificationChannel(channel);
}
void _onNotificationTapped(NotificationResponse response) {
print('Notifica tappata: ${response.payload}');
if (response.payload != null && response.payload!.isNotEmpty) {
try {
final data = jsonDecode(response.payload!);
// Crea un RemoteMessage mock per compatibilità
final message = RemoteMessage(
data: data,
messageId: DateTime.now().toString(),
);
_messageStreamController.add(message);
} catch (e) {
print('Errore parsing payload: $e');
}
}
}
void _configureHandlers() {
FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);
FirebaseMessaging.onMessage.listen(_handleForegroundMessage);
// Handler quando app aperta da notifica (background/terminated)
FirebaseMessaging.onMessageOpenedApp.listen((message) {
print('Notifica aperta (background): ${message.messageId}');
_messageStreamController.add(message);
});
// Controlla se app avviata da notifica
_firebaseMessaging.getInitialMessage().then((message) {
if (message != null) {
print('App avviata da notifica: ${message.messageId}');
_messageStreamController.add(message);
}
});
}
Future<void> _getFcmToken() async {
try {
final token = await _firebaseMessaging.getToken();
if (token != null) {
print('FCM Token: $token');
await AuthService().saveFcmToken(token);
}
_firebaseMessaging.onTokenRefresh.listen((newToken) {
AuthService().saveFcmToken(newToken);
});
} catch (e) {
print('Error getting FCM token: $e');
}
}
Future<void> _handleForegroundMessage(RemoteMessage message) async {
print('Foreground message: ${message.messageId}');
final notification = message.notification;
if (notification != null) {
await _showLocalNotification(
title: notification.title ?? 'ASE Monitor',
body: notification.body ?? 'Nuovo allarme',
payload: jsonEncode(message.data),
);
}
}
Future<void> _showLocalNotification({
required String title,
required String body,
String? payload,
}) async {
const androidDetails = AndroidNotificationDetails(
'allarmi_channel',
'Allarmi',
channelDescription: 'Notifiche per allarmi del sistema di monitoraggio',
importance: Importance.high,
priority: Priority.high,
playSound: true,
enableVibration: true,
icon: '@mipmap/ic_launcher',
);
const iosDetails = DarwinNotificationDetails(
presentAlert: true,
presentBadge: true,
presentSound: true,
);
await _localNotifications.show(
DateTime.now().millisecondsSinceEpoch ~/ 1000,
title,
body,
const NotificationDetails(android: androidDetails, iOS: iosDetails),
payload: payload,
);
}
Future<String?> getFcmToken() async {
try {
return await _firebaseMessaging.getToken();
} catch (e) {
print('Errore ottenimento FCM token: $e');
return null;
}
}
void dispose() {
_messageStreamController.close();
}
}

172
lib/utils/constants.dart Normal file
View File

@@ -0,0 +1,172 @@
import 'package:flutter/material.dart';
/// Costanti dell'applicazione
class AppConstants {
// API Configuration
static const String apiBaseUrl = 'http://10.0.2.2:8000'; // Android emulator
// static const String apiBaseUrl = 'http://localhost:8000'; // iOS simulator
// static const String apiBaseUrl = 'http://YOUR_IP:8000'; // Device reale
static const String apiVersion = 'v1';
// App Info
static const String appName = 'ASE Monitor';
static const String companyName = 'ASE Advanced Slope Engineering';
static const String appVersion = '1.0.0';
// Storage Keys
static const String storageKeyToken = 'auth_token';
static const String storageKeyUser = 'user_data';
static const String storageKeyFcmToken = 'fcm_token';
// Pagination
static const int defaultPageSize = 20;
}
/// Colori del tema ASE
class AppColors {
// Brand Colors
static const Color primary = Color(0xFF1565C0);
static const Color primaryDark = Color(0xFF0D47A1);
static const Color primaryLight = Color(0xFF42A5F5);
static const Color secondary = Color(0xFF00897B);
static const Color secondaryDark = Color(0xFF00695C);
static const Color secondaryLight = Color(0xFF4DB6AC);
static const Color accent = Color(0xFFFF6F00);
// Severity Colors
static const Color critical = Color(0xFFD32F2F);
static const Color warning = Color(0xFFF57C00);
static const Color info = Color(0xFF1976D2);
static const Color success = Color(0xFF388E3C);
// Neutral Colors
static const Color background = Color(0xFFF5F7FA);
static const Color surface = Color(0xFFFFFFFF);
static const Color surfaceDark = Color(0xFF263238);
static const Color textPrimary = Color(0xFF212121);
static const Color textSecondary = Color(0xFF757575);
static const Color textLight = Color(0xFFBDBDBD);
// Status Colors
static const Color statusNuovo = Color(0xFFE91E63);
static const Color statusVisualizzato = Color(0xFF3F51B5);
static const Color statusInGestione = Color(0xFFFF9800);
static const Color statusRisolto = Color(0xFF4CAF50);
// Gradients
static const LinearGradient primaryGradient = LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [primary, primaryDark],
);
static const LinearGradient criticalGradient = LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFFD32F2F), Color(0xFFC62828)],
);
}
/// Dimensioni e spacing
class AppSizes {
static const double paddingXS = 4.0;
static const double paddingS = 8.0;
static const double paddingM = 16.0;
static const double paddingL = 24.0;
static const double paddingXL = 32.0;
static const double radiusS = 8.0;
static const double radiusM = 12.0;
static const double radiusL = 16.0;
static const double radiusXL = 24.0;
static const double iconS = 16.0;
static const double iconM = 24.0;
static const double iconL = 32.0;
static const double iconXL = 48.0;
static const double buttonHeight = 48.0;
static const double inputHeight = 56.0;
}
/// Stili di testo
class AppTextStyles {
static const TextStyle h1 = TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
color: AppColors.textPrimary,
letterSpacing: -0.5,
);
static const TextStyle h2 = TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: AppColors.textPrimary,
letterSpacing: -0.3,
);
static const TextStyle h3 = TextStyle(
fontSize: 20,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
);
static const TextStyle bodyLarge = TextStyle(
fontSize: 16,
fontWeight: FontWeight.normal,
color: AppColors.textPrimary,
height: 1.5,
);
static const TextStyle bodyMedium = TextStyle(
fontSize: 14,
fontWeight: FontWeight.normal,
color: AppColors.textPrimary,
height: 1.5,
);
static const TextStyle bodySmall = TextStyle(
fontSize: 12,
fontWeight: FontWeight.normal,
color: AppColors.textSecondary,
height: 1.4,
);
static const TextStyle button = TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
letterSpacing: 0.5,
);
static const TextStyle caption = TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: AppColors.textSecondary,
letterSpacing: 0.3,
);
}
/// Icone per tipi di allarme
class AlarmTypeIcons {
static const Map<String, IconData> icons = {
'movimento_terreno': Icons.move_down,
'deformazione': Icons.transform,
'accelerazione': Icons.speed,
'inclinazione': Icons.rotate_right,
'fessurazione': Icons.broken_image,
'vibrazione': Icons.vibration,
'temperatura_anomala': Icons.thermostat,
'umidita_anomala': Icons.water_drop,
'perdita_segnale': Icons.signal_wifi_off,
'batteria_scarica': Icons.battery_alert,
'altro': Icons.warning,
};
static IconData getIcon(String type) {
return icons[type.toLowerCase()] ?? Icons.warning;
}
}

98
lib/utils/theme.dart Normal file
View File

@@ -0,0 +1,98 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'constants.dart';
class AppTheme {
static ThemeData get lightTheme {
return ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.light(
primary: AppColors.primary,
secondary: AppColors.secondary,
surface: AppColors.surface,
error: AppColors.critical,
),
scaffoldBackgroundColor: AppColors.background,
appBarTheme: const AppBarTheme(
elevation: 0,
centerTitle: true,
backgroundColor: AppColors.primary,
foregroundColor: Colors.white,
systemOverlayStyle: SystemUiOverlayStyle.light,
titleTextStyle: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
cardTheme: CardThemeData(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppSizes.radiusM),
),
color: AppColors.surface,
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primary,
foregroundColor: Colors.white,
elevation: 2,
padding: const EdgeInsets.symmetric(
horizontal: AppSizes.paddingL,
vertical: AppSizes.paddingM,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppSizes.radiusM),
),
textStyle: AppTextStyles.button,
),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: AppColors.surface,
contentPadding: const EdgeInsets.symmetric(
horizontal: AppSizes.paddingM,
vertical: AppSizes.paddingM,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppSizes.radiusM),
borderSide: const BorderSide(color: AppColors.textLight),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppSizes.radiusM),
borderSide: const BorderSide(color: AppColors.textLight),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppSizes.radiusM),
borderSide: const BorderSide(color: AppColors.primary, width: 2),
),
),
textTheme: const TextTheme(
displayLarge: AppTextStyles.h1,
displayMedium: AppTextStyles.h2,
displaySmall: AppTextStyles.h3,
bodyLarge: AppTextStyles.bodyLarge,
bodyMedium: AppTextStyles.bodyMedium,
bodySmall: AppTextStyles.bodySmall,
),
);
}
static ThemeData get darkTheme {
return ThemeData(
useMaterial3: true,
brightness: Brightness.dark,
colorScheme: ColorScheme.dark(
primary: AppColors.primaryLight,
secondary: AppColors.secondaryLight,
surface: AppColors.surfaceDark,
error: AppColors.critical,
),
);
}
}

View File

@@ -0,0 +1,102 @@
import 'package:flutter/material.dart';
import '../models/allarme.dart';
import '../utils/constants.dart';
import '../screens/allarme_detail_screen.dart';
class AllarmeCard extends StatelessWidget {
final Allarme allarme;
const AllarmeCard({super.key, required this.allarme});
Color _getSeverityColor() {
switch (allarme.severita) {
case 'critical': return AppColors.critical;
case 'warning': return AppColors.warning;
case 'info': return AppColors.info;
default: return AppColors.textSecondary;
}
}
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.only(bottom: AppSizes.paddingM),
child: InkWell(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => AllarmeDetailScreen(allarme: allarme),
),
);
},
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(AppSizes.paddingM),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: _getSeverityColor(),
borderRadius: BorderRadius.circular(8),
),
child: Text(allarme.severitaReadable, style: const TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.bold)),
),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: AppColors.info.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Text(allarme.statoReadable, style: const TextStyle(fontSize: 12)),
),
],
),
const SizedBox(height: 12),
Text(allarme.titolo, style: AppTextStyles.h3, maxLines: 2),
const SizedBox(height: 8),
Row(
children: [
Icon(AlarmTypeIcons.getIcon(allarme.tipo), size: 16, color: AppColors.textSecondary),
const SizedBox(width: 8),
Text(allarme.tipoReadable, style: AppTextStyles.bodyMedium.copyWith(color: AppColors.textSecondary)),
],
),
if (allarme.descrizione != null) ...[
const SizedBox(height: 8),
Text(allarme.descrizione!, style: AppTextStyles.bodyMedium, maxLines: 2, overflow: TextOverflow.ellipsis),
],
if (allarme.valoreRilevato != null && allarme.valoreSoglia != null) ...[
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: _getSeverityColor().withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(Icons.show_chart, size: 16, color: _getSeverityColor()),
const SizedBox(width: 8),
Expanded(
child: Text(
'Valore: ${allarme.valoreRilevato!.toStringAsFixed(2)} ${allarme.unitaMisura ?? ''} (soglia: ${allarme.valoreSoglia!.toStringAsFixed(2)})',
style: AppTextStyles.bodySmall,
),
),
],
),
),
],
],
),
),
),
);
}
}

176
lib/widgets/sito_card.dart Normal file
View File

@@ -0,0 +1,176 @@
import 'package:flutter/material.dart';
import '../models/sito.dart';
import '../utils/constants.dart';
import '../screens/sito_detail_screen.dart';
class SitoCard extends StatelessWidget {
final Sito sito;
const SitoCard({super.key, required this.sito});
IconData _getTipoIcon() {
switch (sito.tipo) {
case 'ponte':
return Icons.architecture;
case 'galleria':
return Icons.south_west;
case 'diga':
return Icons.water_damage;
case 'frana':
return Icons.landscape;
case 'versante':
return Icons.terrain;
case 'edificio':
return Icons.business;
default:
return Icons.location_on;
}
}
Color _getTipoColor() {
switch (sito.tipo) {
case 'ponte':
return Colors.blue;
case 'galleria':
return Colors.brown;
case 'diga':
return Colors.cyan;
case 'frana':
return Colors.orange;
case 'versante':
return Colors.green;
case 'edificio':
return Colors.purple;
default:
return AppColors.primary;
}
}
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.only(bottom: AppSizes.paddingM),
child: InkWell(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => SitoDetailScreen(sito: sito),
),
);
},
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(AppSizes.paddingM),
child: Row(
children: [
// Icona tipo sito
Container(
width: 60,
height: 60,
decoration: BoxDecoration(
color: _getTipoColor().withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
_getTipoIcon(),
size: 32,
color: _getTipoColor(),
),
),
const SizedBox(width: AppSizes.paddingM),
// Informazioni sito
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Nome sito
Text(
sito.nome,
style: AppTextStyles.h3,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
// Tipo sito
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: _getTipoColor().withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Text(
sito.tipoReadable,
style: TextStyle(
fontSize: 12,
color: _getTipoColor(),
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(height: 8),
// Località
Row(
children: [
Icon(
Icons.place,
size: 16,
color: AppColors.textSecondary,
),
const SizedBox(width: 4),
Expanded(
child: Text(
sito.localita,
style: AppTextStyles.bodySmall.copyWith(
color: AppColors.textSecondary,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
// Codice identificativo
if (sito.codiceIdentificativo != null) ...[
const SizedBox(height: 4),
Row(
children: [
Icon(
Icons.qr_code,
size: 16,
color: AppColors.textSecondary,
),
const SizedBox(width: 4),
Text(
sito.codiceIdentificativo!,
style: AppTextStyles.bodySmall.copyWith(
color: AppColors.textSecondary,
fontFamily: 'monospace',
),
),
],
),
],
],
),
),
// Freccia
Icon(
Icons.chevron_right,
color: AppColors.textSecondary,
),
],
),
),
),
);
}
}