713 lines
22 KiB
Dart
713 lines
22 KiB
Dart
// lib/screens/medical_certificates_page.dart
|
|
import 'dart:io';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:image_picker/image_picker.dart';
|
|
import 'package:file_picker/file_picker.dart';
|
|
import 'package:url_launcher/url_launcher.dart';
|
|
|
|
import '../models/school.dart';
|
|
import '../services/medical_certificates_api.dart';
|
|
import '../config/api_config.dart';
|
|
|
|
import '../widgets/app_drawer.dart';
|
|
import '../widgets/app_bottom_nav.dart';
|
|
|
|
import 'home_page.dart';
|
|
import 'lessons_page.dart';
|
|
import 'meditation_page.dart';
|
|
import '../widgets/yogibook_background.dart';
|
|
|
|
class MedicalCertificatesPage extends StatefulWidget {
|
|
final String token;
|
|
final School school;
|
|
final String? userFirstName;
|
|
|
|
const MedicalCertificatesPage({
|
|
super.key,
|
|
required this.token,
|
|
required this.school,
|
|
this.userFirstName,
|
|
});
|
|
|
|
@override
|
|
State<MedicalCertificatesPage> createState() =>
|
|
_MedicalCertificatesPageState();
|
|
}
|
|
|
|
class _MedicalCertificatesPageState extends State<MedicalCertificatesPage> {
|
|
static const Color kBg = Color(0xFFF6F6FB);
|
|
|
|
bool loading = true;
|
|
String error = '';
|
|
List<Map<String, dynamic>> certs = [];
|
|
|
|
// upload flow
|
|
File? pickedFile;
|
|
String pickedLabel = '';
|
|
final docNameCtrl = TextEditingController(text: 'certificato');
|
|
final notesCtrl = TextEditingController();
|
|
DateTime? expiryDate;
|
|
bool uploading = false;
|
|
|
|
// ✅ questa pagina è "sezione account"
|
|
int bottomIndex = 2;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_reload();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
docNameCtrl.dispose();
|
|
notesCtrl.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _reload() async {
|
|
setState(() {
|
|
loading = true;
|
|
error = '';
|
|
});
|
|
try {
|
|
final list = await MedicalCertificatesApi.list(token: widget.token);
|
|
setState(() => certs = list);
|
|
} catch (e) {
|
|
setState(() => error = e.toString());
|
|
} finally {
|
|
setState(() => loading = false);
|
|
}
|
|
}
|
|
|
|
Future<void> _pickFromChooser() async {
|
|
final choice = await showModalBottomSheet<String>(
|
|
context: context,
|
|
showDragHandle: true,
|
|
builder: (_) => SafeArea(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
ListTile(
|
|
leading: const Icon(Icons.photo_camera),
|
|
title: const Text('Fotocamera'),
|
|
onTap: () => Navigator.pop(context, 'camera'),
|
|
),
|
|
ListTile(
|
|
leading: const Icon(Icons.photo_library),
|
|
title: const Text('Galleria'),
|
|
onTap: () => Navigator.pop(context, 'gallery'),
|
|
),
|
|
ListTile(
|
|
leading: const Icon(Icons.picture_as_pdf),
|
|
title: const Text('PDF'),
|
|
onTap: () => Navigator.pop(context, 'pdf'),
|
|
),
|
|
const SizedBox(height: 10),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
|
|
if (choice == null) return;
|
|
|
|
File? f;
|
|
String label = '';
|
|
|
|
if (choice == 'camera' || choice == 'gallery') {
|
|
final picker = ImagePicker();
|
|
final x = await picker.pickImage(
|
|
source: choice == 'camera' ? ImageSource.camera : ImageSource.gallery,
|
|
imageQuality: 85,
|
|
);
|
|
if (x == null) return;
|
|
f = File(x.path);
|
|
label = x.name;
|
|
}
|
|
|
|
if (choice == 'pdf') {
|
|
final res = await FilePicker.platform.pickFiles(
|
|
type: FileType.custom,
|
|
allowedExtensions: const ['pdf'],
|
|
withData: false,
|
|
);
|
|
if (res == null || res.files.isEmpty) return;
|
|
final path = res.files.single.path;
|
|
if (path == null) return;
|
|
f = File(path);
|
|
label = res.files.single.name;
|
|
}
|
|
|
|
if (!mounted) return;
|
|
|
|
setState(() {
|
|
pickedFile = f;
|
|
pickedLabel = label;
|
|
docNameCtrl.text = 'certificato';
|
|
notesCtrl.clear();
|
|
expiryDate = null;
|
|
});
|
|
}
|
|
|
|
Future<void> _pickExpiry() async {
|
|
final now = DateTime.now();
|
|
final d = await showDatePicker(
|
|
context: context,
|
|
firstDate: DateTime(now.year, now.month, now.day),
|
|
lastDate: DateTime(now.year + 10),
|
|
initialDate: expiryDate ?? now,
|
|
);
|
|
if (d == null) return;
|
|
setState(() => expiryDate = d);
|
|
}
|
|
|
|
String _fmtYYYYMMDD(DateTime d) {
|
|
final mm = d.month.toString().padLeft(2, '0');
|
|
final dd = d.day.toString().padLeft(2, '0');
|
|
return '${d.year}-$mm-$dd';
|
|
}
|
|
|
|
String _fmtDDMMYYYY(DateTime d) {
|
|
final dd = d.day.toString().padLeft(2, '0');
|
|
final mm = d.month.toString().padLeft(2, '0');
|
|
return '$dd/$mm/${d.year}';
|
|
}
|
|
|
|
Future<void> _doUpload() async {
|
|
if (pickedFile == null) {
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(const SnackBar(content: Text('Seleziona prima un file.')));
|
|
return;
|
|
}
|
|
if (expiryDate == null) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Seleziona la data di scadenza.')),
|
|
);
|
|
return;
|
|
}
|
|
|
|
setState(() {
|
|
uploading = true;
|
|
error = '';
|
|
});
|
|
|
|
try {
|
|
await MedicalCertificatesApi.upload(
|
|
token: widget.token,
|
|
file: pickedFile!,
|
|
documentName: docNameCtrl.text,
|
|
expiryDateYYYYMMDD: _fmtYYYYMMDD(expiryDate!),
|
|
notes: notesCtrl.text,
|
|
);
|
|
|
|
setState(() {
|
|
pickedFile = null;
|
|
pickedLabel = '';
|
|
docNameCtrl.text = 'certificato';
|
|
notesCtrl.clear();
|
|
expiryDate = null;
|
|
});
|
|
|
|
await _reload();
|
|
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Certificato caricato correttamente.')),
|
|
);
|
|
} catch (e) {
|
|
setState(() => error = e.toString());
|
|
} finally {
|
|
setState(() => uploading = false);
|
|
}
|
|
}
|
|
|
|
Future<void> _openFileUrl(String url) async {
|
|
// url tipico: "/userarea/certificate/xxx"
|
|
// fix plural errato se presente
|
|
var fixed = url.replaceFirst(
|
|
'/userarea/certificates/',
|
|
'/userarea/certificate/',
|
|
);
|
|
|
|
// se non ha /public davanti, lo aggiungiamo
|
|
if (!fixed.startsWith('/public/')) {
|
|
fixed = '/public${fixed.startsWith('/') ? '' : '/'}$fixed';
|
|
}
|
|
|
|
final abs = '${ApiConfig.scheme}://${ApiConfig.host}$fixed';
|
|
final uri = Uri.parse(abs);
|
|
|
|
final ok = await launchUrl(uri, mode: LaunchMode.externalApplication);
|
|
if (!ok && mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Impossibile aprire il file.')),
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> _deleteCert(int id) async {
|
|
final ok = await showDialog<bool>(
|
|
context: context,
|
|
builder: (_) => AlertDialog(
|
|
title: const Text('Elimina certificato'),
|
|
content: const Text('Vuoi davvero eliminare questo certificato?'),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context, false),
|
|
child: const Text('Annulla'),
|
|
),
|
|
ElevatedButton(
|
|
onPressed: () => Navigator.pop(context, true),
|
|
child: const Text('Elimina'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
if (ok != true) return;
|
|
|
|
try {
|
|
await MedicalCertificatesApi.delete(token: widget.token, certId: id);
|
|
await _reload();
|
|
} catch (e) {
|
|
setState(() => error = e.toString());
|
|
}
|
|
}
|
|
|
|
bool _isImagePath(String p) {
|
|
final x = p.toLowerCase();
|
|
return x.endsWith('.jpg') ||
|
|
x.endsWith('.jpeg') ||
|
|
x.endsWith('.png') ||
|
|
x.endsWith('.heic') ||
|
|
x.endsWith('.heif');
|
|
}
|
|
|
|
bool _isPdfPath(String p) => p.toLowerCase().endsWith('.pdf');
|
|
|
|
Future<void> _goTo(Widget page) async {
|
|
if (!mounted) return;
|
|
Navigator.pushReplacement(context, MaterialPageRoute(builder: (_) => page));
|
|
}
|
|
|
|
void _handleBottomNav(int i) {
|
|
if (i == bottomIndex) return;
|
|
setState(() => bottomIndex = i);
|
|
|
|
if (i == 0) {
|
|
_goTo(
|
|
HomePage(
|
|
token: widget.token,
|
|
school: widget.school,
|
|
userFirstName: widget.userFirstName,
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (i == 1) {
|
|
_goTo(
|
|
LessonsPage(
|
|
token: widget.token,
|
|
school: widget.school,
|
|
userFirstName: widget.userFirstName,
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (i == 2) {
|
|
// siamo già in "area account", non facciamo nulla (o TODO)
|
|
return;
|
|
}
|
|
|
|
if (i == 3) {
|
|
_goTo(
|
|
MeditationPage(
|
|
token: widget.token,
|
|
school: widget.school,
|
|
userFirstName: widget.userFirstName,
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
// ✅ Drawer standard (come MeditationPage)
|
|
drawer: AppDrawer(
|
|
token: widget.token,
|
|
school: widget.school,
|
|
userFirstName: widget.userFirstName,
|
|
),
|
|
|
|
appBar: AppBar(
|
|
backgroundColor: Colors.transparent,
|
|
elevation: 0,
|
|
title: const Text(
|
|
'Certificati medici',
|
|
style: TextStyle(fontWeight: FontWeight.w900),
|
|
),
|
|
actions: [
|
|
IconButton(
|
|
onPressed: loading ? null : _reload,
|
|
icon: const Icon(Icons.refresh),
|
|
),
|
|
],
|
|
),
|
|
|
|
// ✅ Bottom nav standard (come MeditationPage)
|
|
bottomNavigationBar: AppBottomNav(
|
|
currentIndex: bottomIndex,
|
|
onTap: _handleBottomNav,
|
|
),
|
|
|
|
body: YogibookBackground(
|
|
child: SafeArea(
|
|
top: false,
|
|
child: RefreshIndicator(
|
|
onRefresh: _reload,
|
|
child: ListView(
|
|
padding: const EdgeInsets.fromLTRB(16, 14, 16, 22),
|
|
children: [
|
|
// 1 SOLO bottone in alto
|
|
SizedBox(
|
|
width: double.infinity,
|
|
child: ElevatedButton.icon(
|
|
onPressed: uploading ? null : _pickFromChooser,
|
|
icon: const Icon(Icons.cloud_upload),
|
|
label: const Text('Carica certificato'),
|
|
),
|
|
),
|
|
|
|
const SizedBox(height: 12),
|
|
|
|
// Preview + form (solo se file selezionato)
|
|
if (pickedFile != null) ...[
|
|
_UploadCard(
|
|
filename: pickedLabel.isNotEmpty
|
|
? pickedLabel
|
|
: pickedFile!.path.split(Platform.pathSeparator).last,
|
|
isImage: _isImagePath(pickedFile!.path),
|
|
isPdf: _isPdfPath(pickedFile!.path),
|
|
file: pickedFile!,
|
|
documentNameController: docNameCtrl,
|
|
notesController: notesCtrl,
|
|
expiryDate: expiryDate,
|
|
onPickExpiry: _pickExpiry,
|
|
onUpload: uploading ? null : _doUpload,
|
|
uploading: uploading,
|
|
expiryLabel: expiryDate == null
|
|
? 'Seleziona data'
|
|
: _fmtDDMMYYYY(expiryDate!),
|
|
),
|
|
const SizedBox(height: 14),
|
|
],
|
|
|
|
if (error.isNotEmpty) ...[
|
|
Text(error, style: const TextStyle(color: Colors.red)),
|
|
const SizedBox(height: 10),
|
|
],
|
|
|
|
Text(
|
|
'Certificati caricati (${certs.length})',
|
|
style: const TextStyle(
|
|
fontWeight: FontWeight.w900,
|
|
fontSize: 16,
|
|
),
|
|
),
|
|
const SizedBox(height: 10),
|
|
|
|
if (loading)
|
|
const Padding(
|
|
padding: EdgeInsets.only(top: 30),
|
|
child: Center(child: CircularProgressIndicator()),
|
|
)
|
|
else if (certs.isEmpty)
|
|
const Padding(
|
|
padding: EdgeInsets.only(top: 26),
|
|
child: Center(child: Text('Nessun certificato caricato')),
|
|
)
|
|
else
|
|
...certs.map((c) {
|
|
final expired = c['is_expired'] == true;
|
|
final docName = (c['document_name'] ?? '').toString();
|
|
final filename = (c['filename'] ?? '').toString();
|
|
final uploadedAt = (c['uploaded_at'] ?? '').toString();
|
|
final expiry = (c['expiry_date'] ?? '').toString();
|
|
final notes = (c['notes'] ?? '').toString();
|
|
final fileUrl = (c['file_url'] ?? '').toString();
|
|
|
|
return Card(
|
|
elevation: 0,
|
|
color: expired ? const Color(0xFFFFEBEE) : Colors.white,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(16),
|
|
),
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(14, 12, 14, 12),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
docName.isNotEmpty
|
|
? docName
|
|
: 'certificato',
|
|
style: const TextStyle(
|
|
fontWeight: FontWeight.w900,
|
|
fontSize: 15,
|
|
),
|
|
),
|
|
),
|
|
if (expired)
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 10,
|
|
vertical: 4,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: const Color(0xFFDC3545),
|
|
borderRadius: BorderRadius.circular(999),
|
|
),
|
|
child: const Text(
|
|
'SCADUTO',
|
|
style: TextStyle(
|
|
color: Colors.white,
|
|
fontWeight: FontWeight.w900,
|
|
fontSize: 11,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 6),
|
|
Text(
|
|
filename,
|
|
style: const TextStyle(
|
|
color: Colors.black54,
|
|
fontWeight: FontWeight.w600,
|
|
fontSize: 12,
|
|
),
|
|
),
|
|
const SizedBox(height: 10),
|
|
|
|
Row(
|
|
children: [
|
|
const Icon(
|
|
Icons.event,
|
|
size: 16,
|
|
color: Colors.black54,
|
|
),
|
|
const SizedBox(width: 6),
|
|
Expanded(
|
|
child: Text(
|
|
'Caricato: $uploadedAt',
|
|
style: const TextStyle(
|
|
color: Colors.black54,
|
|
fontSize: 12,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 6),
|
|
Row(
|
|
children: [
|
|
const Icon(
|
|
Icons.timer,
|
|
size: 16,
|
|
color: Colors.black54,
|
|
),
|
|
const SizedBox(width: 6),
|
|
Expanded(
|
|
child: Text(
|
|
'Scadenza: ${expiry.isEmpty ? '—' : expiry}',
|
|
style: TextStyle(
|
|
color: expired
|
|
? const Color(0xFFDC3545)
|
|
: Colors.black54,
|
|
fontSize: 12,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
|
|
if (notes.trim().isNotEmpty) ...[
|
|
const SizedBox(height: 10),
|
|
Text(notes, style: const TextStyle(fontSize: 12)),
|
|
],
|
|
|
|
const SizedBox(height: 10),
|
|
Row(
|
|
children: [
|
|
TextButton.icon(
|
|
onPressed: () => _openFileUrl(fileUrl),
|
|
icon: const Icon(Icons.open_in_new),
|
|
label: const Text('Apri'),
|
|
),
|
|
const Spacer(),
|
|
TextButton.icon(
|
|
onPressed: () =>
|
|
_deleteCert((c['id'] as num).toInt()),
|
|
icon: const Icon(
|
|
Icons.delete_outline,
|
|
color: Colors.red,
|
|
),
|
|
label: const Text(
|
|
'Elimina',
|
|
style: TextStyle(color: Colors.red),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _UploadCard extends StatelessWidget {
|
|
final String filename;
|
|
final bool isImage;
|
|
final bool isPdf;
|
|
final File file;
|
|
final TextEditingController documentNameController;
|
|
final TextEditingController notesController;
|
|
final DateTime? expiryDate;
|
|
final String expiryLabel;
|
|
final VoidCallback onPickExpiry;
|
|
final VoidCallback? onUpload;
|
|
final bool uploading;
|
|
|
|
const _UploadCard({
|
|
required this.filename,
|
|
required this.isImage,
|
|
required this.isPdf,
|
|
required this.file,
|
|
required this.documentNameController,
|
|
required this.notesController,
|
|
required this.expiryDate,
|
|
required this.expiryLabel,
|
|
required this.onPickExpiry,
|
|
required this.onUpload,
|
|
required this.uploading,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Card(
|
|
elevation: 0,
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(18)),
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(14, 14, 14, 14),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const Text(
|
|
'Anteprima',
|
|
style: TextStyle(fontWeight: FontWeight.w900, fontSize: 14),
|
|
),
|
|
const SizedBox(height: 10),
|
|
|
|
if (isImage)
|
|
ClipRRect(
|
|
borderRadius: BorderRadius.circular(14),
|
|
child: Image.file(
|
|
file,
|
|
height: 160,
|
|
width: double.infinity,
|
|
fit: BoxFit.cover,
|
|
),
|
|
)
|
|
else
|
|
Container(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.all(14),
|
|
decoration: BoxDecoration(
|
|
color: const Color(0xFFF6F6FB),
|
|
borderRadius: BorderRadius.circular(14),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
isPdf ? Icons.picture_as_pdf : Icons.insert_drive_file,
|
|
size: 30,
|
|
),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: Text(
|
|
filename,
|
|
style: const TextStyle(fontWeight: FontWeight.w700),
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
const SizedBox(height: 14),
|
|
|
|
TextField(
|
|
controller: documentNameController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Nome documento',
|
|
border: OutlineInputBorder(),
|
|
),
|
|
),
|
|
const SizedBox(height: 10),
|
|
|
|
OutlinedButton.icon(
|
|
onPressed: onPickExpiry,
|
|
icon: const Icon(Icons.date_range),
|
|
label: Text(expiryLabel),
|
|
),
|
|
|
|
const SizedBox(height: 10),
|
|
|
|
TextField(
|
|
controller: notesController,
|
|
minLines: 2,
|
|
maxLines: 4,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Note (opzionale)',
|
|
border: OutlineInputBorder(),
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
|
|
SizedBox(
|
|
width: double.infinity,
|
|
child: ElevatedButton.icon(
|
|
onPressed: onUpload,
|
|
icon: uploading
|
|
? const SizedBox(
|
|
width: 16,
|
|
height: 16,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
: const Icon(Icons.check),
|
|
label: Text(uploading ? 'Caricamento...' : 'Conferma upload'),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|