vistas de ciudadano, escalar animaciones de mascota, implementacion de chatbot para concientizacion, modificacion de datos de ciudadano, modificacion de vista principal
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:http_parser/http_parser.dart';
|
||||
|
||||
import '../../../core/network/api_client.dart';
|
||||
import '../models/incident.dart';
|
||||
|
||||
final incidentServiceProvider = Provider<IncidentService>((ref) {
|
||||
return IncidentService(ref.read(apiClientProvider));
|
||||
});
|
||||
|
||||
class IncidentService {
|
||||
IncidentService(this._dio);
|
||||
final Dio _dio;
|
||||
|
||||
Future<List<UnitOption>> listUnits() async {
|
||||
final res = await _dio.get<List<dynamic>>('/incidents/units');
|
||||
return (res.data ?? [])
|
||||
.whereType<Map>()
|
||||
.map((e) => UnitOption.fromJson(Map<String, dynamic>.from(e)))
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<IncidentReport> createIncident({
|
||||
required String category,
|
||||
required String description,
|
||||
int? unitId,
|
||||
File? photo,
|
||||
}) async {
|
||||
final form = FormData.fromMap({
|
||||
'category': category,
|
||||
'description': description,
|
||||
if (unitId != null) 'unit_id': unitId,
|
||||
if (photo != null)
|
||||
'photo': await MultipartFile.fromFile(
|
||||
photo.path,
|
||||
filename: photo.path.split(Platform.pathSeparator).last,
|
||||
contentType: MediaType('image', _ext(photo.path)),
|
||||
),
|
||||
});
|
||||
|
||||
final res = await _dio.post<Map<String, dynamic>>(
|
||||
'/incidents',
|
||||
data: form,
|
||||
options: Options(contentType: 'multipart/form-data'),
|
||||
);
|
||||
return IncidentReport.fromJson(res.data!);
|
||||
}
|
||||
|
||||
Future<List<IncidentReport>> myIncidents() async {
|
||||
final res = await _dio.get<List<dynamic>>('/incidents/me');
|
||||
return (res.data ?? [])
|
||||
.whereType<Map>()
|
||||
.map((e) => IncidentReport.fromJson(Map<String, dynamic>.from(e)))
|
||||
.toList();
|
||||
}
|
||||
|
||||
String _ext(String path) {
|
||||
final dot = path.lastIndexOf('.');
|
||||
if (dot == -1) return 'jpeg';
|
||||
final ext = path.substring(dot + 1).toLowerCase();
|
||||
if (ext == 'jpg') return 'jpeg';
|
||||
return ext;
|
||||
}
|
||||
}
|
||||
56
recolecta_app/lib/features/incidents/models/incident.dart
Normal file
56
recolecta_app/lib/features/incidents/models/incident.dart
Normal file
@@ -0,0 +1,56 @@
|
||||
class UnitOption {
|
||||
final int id;
|
||||
final String? plate;
|
||||
final String? status;
|
||||
const UnitOption({required this.id, this.plate, this.status});
|
||||
|
||||
factory UnitOption.fromJson(Map<String, dynamic> json) => UnitOption(
|
||||
id: json['id'] as int,
|
||||
plate: json['plate'] as String?,
|
||||
status: json['status'] as String?,
|
||||
);
|
||||
|
||||
String get label =>
|
||||
plate != null && plate!.isNotEmpty ? 'Unidad $id · $plate' : 'Unidad $id';
|
||||
}
|
||||
|
||||
class IncidentReport {
|
||||
final int id;
|
||||
final String userId;
|
||||
final int? unitId;
|
||||
final String category;
|
||||
final String description;
|
||||
final String? photoUrl;
|
||||
final String status;
|
||||
final String? createdAt;
|
||||
|
||||
const IncidentReport({
|
||||
required this.id,
|
||||
required this.userId,
|
||||
this.unitId,
|
||||
required this.category,
|
||||
required this.description,
|
||||
this.photoUrl,
|
||||
required this.status,
|
||||
this.createdAt,
|
||||
});
|
||||
|
||||
factory IncidentReport.fromJson(Map<String, dynamic> json) => IncidentReport(
|
||||
id: json['id'] as int,
|
||||
userId: json['user_id'] as String,
|
||||
unitId: json['unit_id'] as int?,
|
||||
category: json['category'] as String,
|
||||
description: json['description'] as String,
|
||||
photoUrl: json['photo_url'] as String?,
|
||||
status: (json['status'] as String?) ?? 'open',
|
||||
createdAt: json['created_at'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
const incidentCategories = <String, String>{
|
||||
'derrame': 'Derrame de residuos',
|
||||
'dano_propiedad': 'Daño a propiedad',
|
||||
'conducta': 'Conducta inadecuada',
|
||||
'no_recoleccion': 'No pasó el camión',
|
||||
'otro': 'Otro',
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../data/incident_service.dart';
|
||||
import '../models/incident.dart';
|
||||
|
||||
final unitsProvider = FutureProvider<List<UnitOption>>((ref) async {
|
||||
return ref.read(incidentServiceProvider).listUnits();
|
||||
});
|
||||
|
||||
final myIncidentsProvider = FutureProvider<List<IncidentReport>>((ref) async {
|
||||
return ref.read(incidentServiceProvider).myIncidents();
|
||||
});
|
||||
@@ -0,0 +1,244 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../../core/widgets/app_widgets.dart';
|
||||
import '../data/incident_service.dart';
|
||||
import '../models/incident.dart';
|
||||
import '../providers/incident_providers.dart';
|
||||
|
||||
class ReportIssueScreen extends ConsumerStatefulWidget {
|
||||
const ReportIssueScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ReportIssueScreen> createState() => _ReportIssueScreenState();
|
||||
}
|
||||
|
||||
class _ReportIssueScreenState extends ConsumerState<ReportIssueScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _descCtrl = TextEditingController();
|
||||
|
||||
int? _unitId;
|
||||
String _category = 'no_recoleccion';
|
||||
File? _photo;
|
||||
bool _submitting = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_descCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _pickPhoto() async {
|
||||
final picker = ImagePicker();
|
||||
final picked = await picker.pickImage(
|
||||
source: ImageSource.gallery,
|
||||
maxWidth: 1600,
|
||||
imageQuality: 80,
|
||||
);
|
||||
if (picked != null) {
|
||||
setState(() => _photo = File(picked.path));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
if (!(_formKey.currentState?.validate() ?? false)) return;
|
||||
setState(() => _submitting = true);
|
||||
try {
|
||||
await ref
|
||||
.read(incidentServiceProvider)
|
||||
.createIncident(
|
||||
category: _category,
|
||||
description: _descCtrl.text.trim(),
|
||||
unitId: _unitId,
|
||||
photo: _photo,
|
||||
);
|
||||
ref.invalidate(myIncidentsProvider);
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Reporte enviado. ¡Gracias!')),
|
||||
);
|
||||
Navigator.of(context).pop();
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(_friendly(e))));
|
||||
} finally {
|
||||
if (mounted) setState(() => _submitting = false);
|
||||
}
|
||||
}
|
||||
|
||||
String _friendly(Object e) {
|
||||
if (e is DioException) {
|
||||
final data = e.response?.data;
|
||||
if (data is Map && data['detail'] != null)
|
||||
return data['detail'].toString();
|
||||
}
|
||||
return 'No se pudo enviar el reporte';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final unitsAsync = ref.watch(unitsProvider);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppTheme.background,
|
||||
appBar: AppBar(title: const Text('Reportar un problema')),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: AppCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const AppSectionTitle(title: 'Detalles del reporte'),
|
||||
|
||||
// Unidad
|
||||
Text(
|
||||
'Unidad relacionada (opcional)',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
unitsAsync.when(
|
||||
loading: () => const LinearProgressIndicator(),
|
||||
error: (e, _) => Text(
|
||||
'No se pudieron cargar las unidades',
|
||||
style: const TextStyle(
|
||||
color: AppTheme.danger,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
data: (units) => DropdownButtonFormField<int?>(
|
||||
initialValue: _unitId,
|
||||
isExpanded: true,
|
||||
items: [
|
||||
const DropdownMenuItem<int?>(
|
||||
value: null,
|
||||
child: Text('Sin unidad'),
|
||||
),
|
||||
for (final u in units)
|
||||
DropdownMenuItem<int?>(
|
||||
value: u.id,
|
||||
child: Text(u.label),
|
||||
),
|
||||
],
|
||||
onChanged: (v) => setState(() => _unitId = v),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Categoría
|
||||
Text(
|
||||
'Categoría',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: _category,
|
||||
isExpanded: true,
|
||||
items: [
|
||||
for (final entry in incidentCategories.entries)
|
||||
DropdownMenuItem<String>(
|
||||
value: entry.key,
|
||||
child: Text(entry.value),
|
||||
),
|
||||
],
|
||||
onChanged: (v) {
|
||||
if (v != null) setState(() => _category = v);
|
||||
},
|
||||
validator: (v) => (v == null || v.isEmpty)
|
||||
? 'Selecciona una categoría'
|
||||
: null,
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
AppFormField(
|
||||
label: 'Descripción',
|
||||
controller: _descCtrl,
|
||||
hint: 'Cuéntanos qué pasó…',
|
||||
maxLines: 5,
|
||||
keyboardType: TextInputType.multiline,
|
||||
validator: (v) {
|
||||
final t = (v ?? '').trim();
|
||||
if (t.length < 3)
|
||||
return 'Describe el problema (mínimo 3 caracteres)';
|
||||
return null;
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Foto
|
||||
Row(
|
||||
children: [
|
||||
OutlinedButton.icon(
|
||||
onPressed: _submitting ? null : _pickPhoto,
|
||||
icon: const Icon(Icons.photo_camera_outlined),
|
||||
label: Text(
|
||||
_photo == null ? 'Adjuntar foto' : 'Cambiar foto',
|
||||
),
|
||||
),
|
||||
if (_photo != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
tooltip: 'Quitar foto',
|
||||
icon: const Icon(Icons.close, color: AppTheme.danger),
|
||||
onPressed: _submitting
|
||||
? null
|
||||
: () => setState(() => _photo = null),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
if (_photo != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(AppTheme.radiusLg),
|
||||
child: Image.file(
|
||||
_photo!,
|
||||
height: 180,
|
||||
width: double.infinity,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: _submitting ? null : _submit,
|
||||
child: _submitting
|
||||
? const SizedBox(
|
||||
height: 18,
|
||||
width: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('Enviar reporte'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user