simulacion de estados y flujo de notificacion, modificacion de estilos en todas las vistas

This commit is contained in:
shinra32
2026-05-23 07:08:49 -06:00
parent ca076607c7
commit 92f570294a
43 changed files with 4335 additions and 2035 deletions

View File

@@ -16,13 +16,38 @@ class IncidentService {
final Dio _dio;
Future<List<UnitOption>> listUnits() async {
final res = await _dio.get<List<dynamic>>('/incidents/units');
final res = await _dio.get<List<dynamic>>(
'/incidents/units',
options: Options(
receiveTimeout: const Duration(seconds: 6),
sendTimeout: const Duration(seconds: 6),
),
);
return (res.data ?? [])
.whereType<Map>()
.map((e) => UnitOption.fromJson(Map<String, dynamic>.from(e)))
.toList();
}
/// Devuelve la unidad asignada al domicilio (vía su ruta).
/// `null` si el backend responde 404 (sin ruta o sin unidad).
Future<UnitOption?> getAddressUnit(String addressId) async {
try {
final res = await _dio.get<Map<String, dynamic>>(
'/addresses/$addressId/unit',
options: Options(
receiveTimeout: const Duration(seconds: 6),
sendTimeout: const Duration(seconds: 6),
),
);
if (res.data == null) return null;
return UnitOption.fromJson(res.data!);
} on DioException catch (e) {
if (e.response?.statusCode == 404) return null;
rethrow;
}
}
Future<IncidentReport> createIncident({
required String category,
required String description,

View File

@@ -1,5 +1,6 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../eta/eta_provider.dart';
import '../data/incident_service.dart';
import '../models/incident.dart';
@@ -10,3 +11,13 @@ final unitsProvider = FutureProvider<List<UnitOption>>((ref) async {
final myIncidentsProvider = FutureProvider<List<IncidentReport>>((ref) async {
return ref.read(incidentServiceProvider).myIncidents();
});
/// Unidad asignada al domicilio activo del ciudadano.
/// Se deriva en backend a partir de `addresses.route_id → routes.truck_id`.
/// Devuelve `null` si el ciudadano aún no tiene una dirección activa
/// o si su ruta no tiene unidad asignada.
final assignedUnitProvider = FutureProvider<UnitOption?>((ref) async {
final addressId = ref.watch(activeAddressIdProvider);
if (addressId == null) return null;
return ref.read(incidentServiceProvider).getAddressUnit(addressId);
});

View File

@@ -22,7 +22,6 @@ class _ReportIssueScreenState extends ConsumerState<ReportIssueScreen> {
final _formKey = GlobalKey<FormState>();
final _descCtrl = TextEditingController();
int? _unitId;
String _category = 'no_recoleccion';
File? _photo;
bool _submitting = false;
@@ -49,12 +48,13 @@ class _ReportIssueScreenState extends ConsumerState<ReportIssueScreen> {
if (!(_formKey.currentState?.validate() ?? false)) return;
setState(() => _submitting = true);
try {
final assignedUnit = ref.read(assignedUnitProvider).value;
await ref
.read(incidentServiceProvider)
.createIncident(
category: _category,
description: _descCtrl.text.trim(),
unitId: _unitId,
unitId: assignedUnit?.id,
photo: _photo,
);
ref.invalidate(myIncidentsProvider);
@@ -76,169 +76,357 @@ class _ReportIssueScreenState extends ConsumerState<ReportIssueScreen> {
String _friendly(Object e) {
if (e is DioException) {
final data = e.response?.data;
if (data is Map && data['detail'] != null)
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);
final assignedUnitAsync = ref.watch(assignedUnitProvider);
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(
body: Form(
key: _formKey,
child: CustomScrollView(
slivers: [
SliverToBoxAdapter(child: _buildPageHeader(context)),
SliverPadding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 0),
sliver: SliverList(
delegate: SliverChildListDelegate([
const AppSectionTitle(title: 'Detalles del reporte'),
AppFormCard(
icon: Icons.local_shipping_outlined,
title: 'Unidad asignada a tu zona',
child: _AssignedUnitBadge(
assignedUnitAsync: assignedUnitAsync,
),
),
const SizedBox(height: 16),
AppFormCard(
icon: Icons.category_outlined,
title: 'Categoría del problema',
child: DropdownButtonFormField<String>(
initialValue: _category,
isExpanded: true,
decoration: const InputDecoration(
border: OutlineInputBorder(),
contentPadding: EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
),
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),
AppFormCard(
icon: Icons.description_outlined,
title: 'Descripción',
child: AppFormField(
label: 'Cuéntanos qué pasó',
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),
AppFormCard(
icon: Icons.photo_camera_outlined,
title: 'Evidencia fotográfica',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: _submitting ? null : _pickPhoto,
style: OutlinedButton.styleFrom(
foregroundColor: AppTheme.primary,
side: const BorderSide(
color: AppTheme.primary,
),
),
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.radiusMd),
child: Image.file(
_photo!,
height: 180,
width: double.infinity,
fit: BoxFit.cover,
),
),
],
],
),
),
const SizedBox(height: 32),
]),
),
),
],
),
),
bottomNavigationBar: _buildSubmitButton(),
);
}
Widget _buildPageHeader(BuildContext context) {
return Container(
padding: EdgeInsets.fromLTRB(
20,
MediaQuery.of(context).padding.top + 12,
20,
24,
),
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: [Color(0xFF4A0E26), Color(0xFF9B1B4A)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.only(
bottomLeft: Radius.circular(28),
bottomRight: Radius.circular(28),
),
),
child: Row(
children: [
GestureDetector(
onTap: () => Navigator.of(context).pop(),
child: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(10),
),
child: const Icon(
Icons.arrow_back,
color: Colors.white,
size: 20,
),
),
),
const SizedBox(width: 14),
const Expanded(
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,
'Reportar un problema',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
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
SizedBox(height: 2),
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'),
),
'Ayúdanos a mejorar el servicio',
style: TextStyle(fontSize: 13, color: Colors.white70),
),
],
),
),
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(12),
),
child: const Icon(
Icons.bug_report_outlined,
color: Colors.white,
size: 22,
),
),
],
),
);
}
Widget _buildSubmitButton() {
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 12, 24, 16),
child: SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _submitting ? null : _submit,
child: _submitting
? const SizedBox(
height: 18,
width: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Text('Enviar reporte'),
),
),
),
);
}
}
class _AssignedUnitBadge extends StatelessWidget {
const _AssignedUnitBadge({required this.assignedUnitAsync});
final AsyncValue<UnitOption?> assignedUnitAsync;
@override
Widget build(BuildContext context) {
return assignedUnitAsync.when(
loading: () => Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: AppTheme.background,
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
border: Border.all(color: AppTheme.border),
),
child: const Row(
children: [
SizedBox(
height: 14,
width: 14,
child: CircularProgressIndicator(strokeWidth: 2),
),
SizedBox(width: 10),
Text(
'Detectando unidad asignada…',
style: TextStyle(fontSize: 13),
),
],
),
),
error: (_, _) => Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: AppTheme.dangerLight,
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
border: Border.all(color: AppTheme.danger.withValues(alpha: 0.3)),
),
child: const Row(
children: [
Icon(Icons.error_outline, size: 16, color: AppTheme.danger),
SizedBox(width: 8),
Expanded(
child: Text(
'No se pudo obtener la unidad asignada. El reporte se enviará sin unidad.',
style: TextStyle(fontSize: 12, color: AppTheme.danger),
),
),
],
),
),
data: (unit) {
if (unit == null) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: AppTheme.amberLight,
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
border: Border.all(
color: AppTheme.amber.withValues(alpha: 0.4),
),
),
child: const Row(
children: [
Icon(Icons.info_outline, size: 16, color: AppTheme.amber),
SizedBox(width: 8),
Expanded(
child: Text(
'Tu zona aún no tiene una unidad asignada.',
style: TextStyle(fontSize: 13, color: AppTheme.amber),
),
),
],
),
);
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: AppTheme.primaryLight,
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
border: Border.all(color: AppTheme.primaryMid),
),
child: Row(
children: [
const Icon(
Icons.local_shipping_outlined,
size: 18,
color: AppTheme.primaryDark,
),
const SizedBox(width: 8),
Expanded(
child: Text(
unit.label,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: AppTheme.primaryDark,
),
),
),
],
),
);
},
);
}
}