simulacion de estados y flujo de notificacion, modificacion de estilos en todas las vistas
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user