App móvil Flutter para capturar contenido multimedia, etiquetarlo con hashes y enviarlo a backends configurables. Features: - Captura de fotos, audio, video y archivos - Sistema de etiquetas con bibliotecas externas (HST) - Packs de etiquetas predefinidos - Cola de reintentos (hasta 20 contenedores) - Soporte GPS - Hash SHA-256 auto-generado por contenedor - Persistencia SQLite local - Múltiples destinos configurables Stack: Flutter 3.38.5, flutter_bloc, sqflite, dio 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
312 lines
12 KiB
Dart
312 lines
12 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
import '../bloc/captura/captura_cubit.dart';
|
|
import '../bloc/captura/captura_state.dart';
|
|
import '../bloc/etiquetas/etiquetas_cubit.dart';
|
|
import '../bloc/app/app_cubit.dart';
|
|
import '../bloc/app/app_state.dart';
|
|
import '../widgets/audio_recorder.dart';
|
|
import '../../core/utils/hash_utils.dart';
|
|
|
|
class CapturaPage extends StatelessWidget {
|
|
const CapturaPage({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return BlocBuilder<CapturaCubit, CapturaState>(
|
|
builder: (context, state) {
|
|
return Scaffold(
|
|
body: SingleChildScrollView(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
// Hash section
|
|
Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
const Icon(Icons.tag, size: 20),
|
|
const SizedBox(width: 8),
|
|
const Text('Hash', style: TextStyle(fontWeight: FontWeight.bold)),
|
|
const Spacer(),
|
|
IconButton(
|
|
icon: const Icon(Icons.copy, size: 20),
|
|
onPressed: () {
|
|
Clipboard.setData(ClipboardData(text: state.contenedor.hash));
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Hash copiado')),
|
|
);
|
|
},
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.refresh, size: 20),
|
|
onPressed: () => context.read<CapturaCubit>().regenerateHash(),
|
|
),
|
|
],
|
|
),
|
|
Text(
|
|
HashUtils.truncateHash(state.contenedor.hash, length: 32),
|
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
fontFamily: 'monospace',
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
|
|
// Title & Description
|
|
TextField(
|
|
decoration: const InputDecoration(
|
|
labelText: 'Título (opcional)',
|
|
prefixIcon: Icon(Icons.title),
|
|
),
|
|
onChanged: (v) => context.read<CapturaCubit>().setTitulo(v),
|
|
),
|
|
const SizedBox(height: 12),
|
|
TextField(
|
|
decoration: const InputDecoration(
|
|
labelText: 'Descripción (opcional)',
|
|
prefixIcon: Icon(Icons.description),
|
|
),
|
|
maxLines: 2,
|
|
onChanged: (v) => context.read<CapturaCubit>().setDescripcion(v),
|
|
),
|
|
const SizedBox(height: 16),
|
|
|
|
// Capture buttons
|
|
Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const Text('Capturar', style: TextStyle(fontWeight: FontWeight.bold)),
|
|
const SizedBox(height: 12),
|
|
Wrap(
|
|
spacing: 12,
|
|
runSpacing: 12,
|
|
children: [
|
|
_CaptureButton(
|
|
icon: Icons.camera_alt,
|
|
label: 'Foto',
|
|
onPressed: () => context.read<CapturaCubit>().capturePhoto(),
|
|
),
|
|
AudioRecorderButton(
|
|
onRecorded: (bytes, name) {
|
|
context.read<CapturaCubit>().addAudioFile(bytes, name);
|
|
},
|
|
),
|
|
_CaptureButton(
|
|
icon: Icons.videocam,
|
|
label: 'Video',
|
|
onPressed: () => context.read<CapturaCubit>().captureVideo(),
|
|
),
|
|
_CaptureButton(
|
|
icon: Icons.attach_file,
|
|
label: 'Archivo',
|
|
onPressed: () => context.read<CapturaCubit>().pickFile(),
|
|
),
|
|
_CaptureButton(
|
|
icon: state.contenedor.gps != null
|
|
? Icons.location_on
|
|
: Icons.location_off,
|
|
label: 'GPS',
|
|
onPressed: () {
|
|
if (state.contenedor.gps != null) {
|
|
context.read<CapturaCubit>().clearGps();
|
|
} else {
|
|
context.read<CapturaCubit>().captureGps();
|
|
}
|
|
},
|
|
selected: state.contenedor.gps != null,
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
|
|
// Attached files
|
|
if (state.contenedor.archivos.isNotEmpty) ...[
|
|
Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'Archivos (${state.contenedor.archivos.length})',
|
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
|
),
|
|
const SizedBox(height: 8),
|
|
...state.contenedor.archivos.asMap().entries.map((e) {
|
|
final archivo = e.value;
|
|
return ListTile(
|
|
leading: Icon(_getFileIcon(archivo.tipo)),
|
|
title: Text(archivo.nombre),
|
|
subtitle: Text(archivo.sizeFormatted),
|
|
trailing: IconButton(
|
|
icon: const Icon(Icons.close),
|
|
onPressed: () =>
|
|
context.read<CapturaCubit>().removeArchivo(e.key),
|
|
),
|
|
);
|
|
}),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
],
|
|
|
|
// Etiquetas summary
|
|
BlocBuilder<EtiquetasCubit, dynamic>(
|
|
builder: (context, etState) {
|
|
final seleccionadas = (etState as dynamic).seleccionadas as List<String>;
|
|
if (seleccionadas.isEmpty) {
|
|
return const SizedBox.shrink();
|
|
}
|
|
return Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'Etiquetas (${seleccionadas.length})',
|
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Wrap(
|
|
spacing: 8,
|
|
runSpacing: 8,
|
|
children: seleccionadas
|
|
.map((h) => Chip(
|
|
label: Text(HashUtils.truncateHash(h)),
|
|
onDeleted: () =>
|
|
context.read<EtiquetasCubit>().removeEtiqueta(h),
|
|
))
|
|
.toList(),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
|
|
// GPS info
|
|
if (state.contenedor.gps != null) ...[
|
|
const SizedBox(height: 16),
|
|
Card(
|
|
child: ListTile(
|
|
leading: const Icon(Icons.location_on, color: Colors.green),
|
|
title: const Text('Ubicación'),
|
|
subtitle: Text(state.contenedor.gps!.toString()),
|
|
),
|
|
),
|
|
],
|
|
|
|
const SizedBox(height: 24),
|
|
|
|
// Send button
|
|
BlocBuilder<AppCubit, AppState>(
|
|
builder: (context, appState) {
|
|
final destino = appState.destinoActivo;
|
|
return FilledButton.icon(
|
|
onPressed: destino != null && state.status != CapturaStatus.sending
|
|
? () {
|
|
final etiquetas = context
|
|
.read<EtiquetasCubit>()
|
|
.state
|
|
.seleccionadas;
|
|
context.read<CapturaCubit>().setEtiquetas(etiquetas);
|
|
context.read<CapturaCubit>().enviar(destino);
|
|
}
|
|
: null,
|
|
icon: state.status == CapturaStatus.sending
|
|
? const SizedBox(
|
|
width: 20,
|
|
height: 20,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
: const Icon(Icons.send),
|
|
label: Text(state.status == CapturaStatus.sending
|
|
? 'Enviando...'
|
|
: 'Enviar'),
|
|
);
|
|
},
|
|
),
|
|
|
|
if (state.status == CapturaStatus.error && state.errorMessage != null)
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 16),
|
|
child: Text(
|
|
state.errorMessage!,
|
|
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
IconData _getFileIcon(dynamic tipo) {
|
|
switch (tipo.toString()) {
|
|
case 'FileType.image':
|
|
return Icons.image;
|
|
case 'FileType.audio':
|
|
return Icons.audiotrack;
|
|
case 'FileType.video':
|
|
return Icons.videocam;
|
|
default:
|
|
return Icons.insert_drive_file;
|
|
}
|
|
}
|
|
}
|
|
|
|
class _CaptureButton extends StatelessWidget {
|
|
final IconData icon;
|
|
final String label;
|
|
final VoidCallback onPressed;
|
|
final bool selected;
|
|
|
|
const _CaptureButton({
|
|
required this.icon,
|
|
required this.label,
|
|
required this.onPressed,
|
|
this.selected = false,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return FilledButton.tonal(
|
|
onPressed: onPressed,
|
|
style: FilledButton.styleFrom(
|
|
backgroundColor: selected ? Theme.of(context).colorScheme.primaryContainer : null,
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(icon, size: 18),
|
|
const SizedBox(width: 4),
|
|
Text(label),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|