PACKET v1.0.0 - Initial release

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>
This commit is contained in:
tzzrgit
2025-12-21 18:10:27 +01:00
commit dac0c51483
163 changed files with 8603 additions and 0 deletions

View File

@@ -0,0 +1,33 @@
import 'dart:typed_data';
enum ArchivoTipo { image, audio, video, document }
class ArchivoAdjunto {
final String nombre;
final String mimeType;
final Uint8List bytes;
final ArchivoTipo tipo;
final String? hash;
ArchivoAdjunto({
required this.nombre,
required this.mimeType,
required this.bytes,
required this.tipo,
this.hash,
});
Map<String, dynamic> toJson() => {
'nombre': nombre,
'tipo': mimeType,
'contenido': bytes,
};
int get sizeBytes => bytes.length;
String get sizeFormatted {
if (sizeBytes < 1024) return '$sizeBytes B';
if (sizeBytes < 1024 * 1024) return '${(sizeBytes / 1024).toStringAsFixed(1)} KB';
return '${(sizeBytes / (1024 * 1024)).toStringAsFixed(1)} MB';
}
}

View File

@@ -0,0 +1,33 @@
class Biblioteca {
final int? id;
final String nombre;
final String url;
final String endpoint;
final String? hBiblioteca;
Biblioteca({
this.id,
required this.nombre,
required this.url,
required this.endpoint,
this.hBiblioteca,
});
String get fullUrl => '$url$endpoint';
factory Biblioteca.fromMap(Map<String, dynamic> map) => Biblioteca(
id: map['id'] as int?,
nombre: map['nombre'] as String,
url: map['url'] as String,
endpoint: map['endpoint'] as String,
hBiblioteca: map['h_biblioteca'] as String?,
);
Map<String, dynamic> toMap() => {
if (id != null) 'id': id,
'nombre': nombre,
'url': url,
'endpoint': endpoint,
'h_biblioteca': hBiblioteca,
};
}

View File

@@ -0,0 +1,55 @@
import 'archivo_adjunto.dart';
import 'gps_location.dart';
class Contenedor {
final String hash;
final String? titulo;
final String? descripcion;
final List<ArchivoAdjunto> archivos;
final GpsLocation? gps;
final List<String> etiquetas;
final DateTime createdAt;
Contenedor({
required this.hash,
this.titulo,
this.descripcion,
List<ArchivoAdjunto>? archivos,
this.gps,
List<String>? etiquetas,
DateTime? createdAt,
}) : archivos = archivos ?? [],
etiquetas = etiquetas ?? [],
createdAt = createdAt ?? DateTime.now();
Contenedor copyWith({
String? hash,
String? titulo,
String? descripcion,
List<ArchivoAdjunto>? archivos,
GpsLocation? gps,
List<String>? etiquetas,
DateTime? createdAt,
}) =>
Contenedor(
hash: hash ?? this.hash,
titulo: titulo ?? this.titulo,
descripcion: descripcion ?? this.descripcion,
archivos: archivos ?? this.archivos,
gps: gps ?? this.gps,
etiquetas: etiquetas ?? this.etiquetas,
createdAt: createdAt ?? this.createdAt,
);
Map<String, dynamic> toJson() => {
'hash': hash,
if (titulo != null) 'titulo': titulo,
if (descripcion != null) 'descripcion': descripcion,
'etiquetas': etiquetas,
if (gps != null) 'gps': gps!.toJson(),
'archivos': archivos.map((a) => a.toJson()).toList(),
};
bool get isEmpty => archivos.isEmpty && etiquetas.isEmpty && titulo == null;
int get archivoCount => archivos.length;
}

View File

@@ -0,0 +1,46 @@
class Destino {
final int? id;
final String nombre;
final String url;
final String hash;
final bool activo;
Destino({
this.id,
required this.nombre,
required this.url,
required this.hash,
this.activo = true,
});
factory Destino.fromMap(Map<String, dynamic> map) => Destino(
id: map['id'] as int?,
nombre: map['nombre'] as String,
url: map['url'] as String,
hash: map['hash'] as String,
activo: (map['activo'] as int?) == 1,
);
Map<String, dynamic> toMap() => {
if (id != null) 'id': id,
'nombre': nombre,
'url': url,
'hash': hash,
'activo': activo ? 1 : 0,
};
Destino copyWith({
int? id,
String? nombre,
String? url,
String? hash,
bool? activo,
}) =>
Destino(
id: id ?? this.id,
nombre: nombre ?? this.nombre,
url: url ?? this.url,
hash: hash ?? this.hash,
activo: activo ?? this.activo,
);
}

View File

@@ -0,0 +1,49 @@
class Etiqueta {
final String hMaestro;
final String hGlobal;
final String? mrf;
final String? ref;
final String? nombreEs;
final String? nombreEn;
final String grupo;
final bool activo;
final int? bibliotecaId;
Etiqueta({
required this.hMaestro,
required this.hGlobal,
this.mrf,
this.ref,
this.nombreEs,
this.nombreEn,
required this.grupo,
this.activo = true,
this.bibliotecaId,
});
String get displayName => nombreEs ?? nombreEn ?? ref ?? hMaestro.substring(0, 8);
String? get imagenUrl => mrf != null ? 'https://tzrtech.org/$mrf.png' : null;
factory Etiqueta.fromJson(Map<String, dynamic> json) => Etiqueta(
hMaestro: json['h_maestro'] as String,
hGlobal: json['h_global'] as String,
mrf: json['mrf'] as String?,
ref: json['ref'] as String?,
nombreEs: json['nombre_es'] as String?,
nombreEn: json['nombre_en'] as String?,
grupo: json['grupo'] as String? ?? 'default',
activo: json['activo'] as bool? ?? true,
);
Map<String, dynamic> toMap() => {
'h_maestro': hMaestro,
'h_global': hGlobal,
'mrf': mrf,
'ref': ref,
'nombre_es': nombreEs,
'nombre_en': nombreEn,
'grupo': grupo,
'biblioteca_id': bibliotecaId,
};
}

View File

@@ -0,0 +1,16 @@
class GpsLocation {
final double lat;
final double long;
GpsLocation({required this.lat, required this.long});
Map<String, dynamic> toJson() => {'lat': lat, 'long': long};
factory GpsLocation.fromJson(Map<String, dynamic> json) => GpsLocation(
lat: (json['lat'] as num).toDouble(),
long: (json['long'] as num).toDouble(),
);
@override
String toString() => '${lat.toStringAsFixed(4)}, ${long.toStringAsFixed(4)}';
}

View File

@@ -0,0 +1,29 @@
class Pack {
final int? id;
final String nombre;
final String icono;
final List<String> tags;
Pack({
this.id,
required this.nombre,
this.icono = '📦',
required this.tags,
});
factory Pack.fromMap(Map<String, dynamic> map) => Pack(
id: map['id'] as int?,
nombre: map['nombre'] as String,
icono: map['icono'] as String? ?? '📦',
tags: (map['tags'] as String).split(',').where((t) => t.isNotEmpty).toList(),
);
Map<String, dynamic> toMap() => {
if (id != null) 'id': id,
'nombre': nombre,
'icono': icono,
'tags': tags.join(','),
};
int get tagCount => tags.length;
}

View File

@@ -0,0 +1,55 @@
import 'dart:typed_data';
class Pendiente {
final String hash;
final String? titulo;
final Uint8List contenido;
final int intentos;
final DateTime? ultimoIntento;
final DateTime? proximoIntento;
final int? destinoId;
Pendiente({
required this.hash,
this.titulo,
required this.contenido,
this.intentos = 0,
this.ultimoIntento,
this.proximoIntento,
this.destinoId,
});
factory Pendiente.fromMap(Map<String, dynamic> map) => Pendiente(
hash: map['hash'] as String,
titulo: map['titulo'] as String?,
contenido: map['contenido'] as Uint8List,
intentos: map['intentos'] as int? ?? 0,
ultimoIntento: map['ultimo_intento'] != null
? DateTime.parse(map['ultimo_intento'] as String)
: null,
proximoIntento: map['proximo_intento'] != null
? DateTime.parse(map['proximo_intento'] as String)
: null,
destinoId: map['destino_id'] as int?,
);
Map<String, dynamic> toMap() => {
'hash': hash,
'titulo': titulo,
'contenido': contenido,
'intentos': intentos,
'ultimo_intento': ultimoIntento?.toIso8601String(),
'proximo_intento': proximoIntento?.toIso8601String(),
'destino_id': destinoId,
};
bool get puedeReintentar => intentos < 20;
String get estado {
if (intentos >= 20) return 'agotado';
if (proximoIntento != null && proximoIntento!.isAfter(DateTime.now())) {
return 'esperando';
}
return 'listo';
}
}