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:
119
lib/data/datasources/backend_api.dart
Normal file
119
lib/data/datasources/backend_api.dart
Normal file
@@ -0,0 +1,119 @@
|
||||
import 'dart:convert';
|
||||
import 'package:dio/dio.dart';
|
||||
import '../../core/constants/app_constants.dart';
|
||||
import '../../core/errors/exceptions.dart';
|
||||
import '../../domain/entities/contenedor.dart';
|
||||
import '../../domain/entities/destino.dart';
|
||||
|
||||
class BackendApi {
|
||||
final Dio _dio;
|
||||
|
||||
BackendApi() : _dio = Dio() {
|
||||
_dio.options.connectTimeout = AppConstants.httpTimeout;
|
||||
_dio.options.receiveTimeout = AppConstants.httpTimeout;
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> enviarContenedor(
|
||||
Contenedor contenedor,
|
||||
Destino destino,
|
||||
) async {
|
||||
try {
|
||||
final archivosJson = contenedor.archivos.map((a) => {
|
||||
'nombre': a.nombre,
|
||||
'tipo': a.mimeType,
|
||||
'contenido': base64Encode(a.bytes),
|
||||
}).toList();
|
||||
|
||||
final response = await _dio.post(
|
||||
'${destino.url}/ingest',
|
||||
options: Options(
|
||||
headers: {
|
||||
'X-Auth-Key': destino.hash,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
),
|
||||
data: {
|
||||
'hash': contenedor.hash,
|
||||
if (contenedor.titulo != null) 'titulo': contenedor.titulo,
|
||||
if (contenedor.descripcion != null) 'descripcion': contenedor.descripcion,
|
||||
'etiquetas': contenedor.etiquetas,
|
||||
if (contenedor.gps != null) 'gps': contenedor.gps!.toJson(),
|
||||
'archivos': archivosJson,
|
||||
},
|
||||
);
|
||||
|
||||
return response.data as Map<String, dynamic>;
|
||||
} on DioException catch (e) {
|
||||
if (e.response?.statusCode == 409) {
|
||||
throw HashExistsException();
|
||||
}
|
||||
throw NetworkException(
|
||||
e.message ?? 'Network error',
|
||||
code: e.response?.statusCode?.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> initChunkUpload(
|
||||
String hash,
|
||||
int totalChunks,
|
||||
String fileName,
|
||||
Destino destino,
|
||||
) async {
|
||||
try {
|
||||
final response = await _dio.post(
|
||||
'${destino.url}/upload/init',
|
||||
options: Options(
|
||||
headers: {'X-Auth-Key': destino.hash},
|
||||
),
|
||||
data: {
|
||||
'hash': hash,
|
||||
'total_chunks': totalChunks,
|
||||
'file_name': fileName,
|
||||
},
|
||||
);
|
||||
return response.data['upload_id'] as String;
|
||||
} on DioException catch (e) {
|
||||
throw NetworkException(e.message ?? 'Failed to init upload');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> uploadChunk(
|
||||
String uploadId,
|
||||
int chunkNumber,
|
||||
List<int> bytes,
|
||||
Destino destino,
|
||||
) async {
|
||||
try {
|
||||
await _dio.post(
|
||||
'${destino.url}/upload/chunk/$uploadId/$chunkNumber',
|
||||
options: Options(
|
||||
headers: {
|
||||
'X-Auth-Key': destino.hash,
|
||||
'Content-Type': 'application/octet-stream',
|
||||
},
|
||||
),
|
||||
data: bytes,
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
throw NetworkException(e.message ?? 'Failed to upload chunk');
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> completeUpload(
|
||||
String uploadId,
|
||||
Destino destino,
|
||||
) async {
|
||||
try {
|
||||
final response = await _dio.post(
|
||||
'${destino.url}/upload/complete/$uploadId',
|
||||
options: Options(
|
||||
headers: {'X-Auth-Key': destino.hash},
|
||||
),
|
||||
);
|
||||
return response.data as Map<String, dynamic>;
|
||||
} on DioException catch (e) {
|
||||
throw NetworkException(e.message ?? 'Failed to complete upload');
|
||||
}
|
||||
}
|
||||
}
|
||||
42
lib/data/datasources/biblioteca_api.dart
Normal file
42
lib/data/datasources/biblioteca_api.dart
Normal file
@@ -0,0 +1,42 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import '../../core/constants/app_constants.dart';
|
||||
import '../../core/errors/exceptions.dart';
|
||||
import '../../domain/entities/biblioteca.dart';
|
||||
import '../../domain/entities/etiqueta.dart';
|
||||
|
||||
class BibliotecaApi {
|
||||
final Dio _dio;
|
||||
|
||||
BibliotecaApi() : _dio = Dio() {
|
||||
_dio.options.connectTimeout = AppConstants.httpTimeout;
|
||||
_dio.options.receiveTimeout = AppConstants.httpTimeout;
|
||||
}
|
||||
|
||||
Future<List<Etiqueta>> fetchEtiquetas(Biblioteca biblioteca) async {
|
||||
try {
|
||||
final response = await _dio.get(biblioteca.fullUrl);
|
||||
final data = response.data as Map<String, dynamic>;
|
||||
final results = data['results'] as List<dynamic>;
|
||||
return results
|
||||
.map((e) => Etiqueta.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
} on DioException catch (e) {
|
||||
throw NetworkException(e.message ?? 'Failed to fetch etiquetas');
|
||||
}
|
||||
}
|
||||
|
||||
Future<Etiqueta?> resolveHash(String hash, Biblioteca biblioteca) async {
|
||||
try {
|
||||
final response = await _dio.get(
|
||||
biblioteca.fullUrl,
|
||||
queryParameters: {'hash': hash},
|
||||
);
|
||||
final data = response.data as Map<String, dynamic>;
|
||||
final results = data['results'] as List<dynamic>;
|
||||
if (results.isEmpty) return null;
|
||||
return Etiqueta.fromJson(results.first as Map<String, dynamic>);
|
||||
} on DioException {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
125
lib/data/datasources/local_database.dart
Normal file
125
lib/data/datasources/local_database.dart
Normal file
@@ -0,0 +1,125 @@
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
import 'package:path/path.dart';
|
||||
|
||||
class LocalDatabase {
|
||||
static Database? _database;
|
||||
static const String _dbName = 'packet.db';
|
||||
static const int _dbVersion = 1;
|
||||
|
||||
static Future<Database> get database async {
|
||||
_database ??= await _initDatabase();
|
||||
return _database!;
|
||||
}
|
||||
|
||||
static Future<Database> _initDatabase() async {
|
||||
final path = join(await getDatabasesPath(), _dbName);
|
||||
return openDatabase(
|
||||
path,
|
||||
version: _dbVersion,
|
||||
onCreate: _onCreate,
|
||||
);
|
||||
}
|
||||
|
||||
static Future<void> _onCreate(Database db, int version) async {
|
||||
await db.execute('''
|
||||
CREATE TABLE registro (
|
||||
hash TEXT PRIMARY KEY,
|
||||
titulo TEXT,
|
||||
hora_envio TEXT NOT NULL,
|
||||
primera_conf TEXT,
|
||||
ultima_conf TEXT,
|
||||
destino_id INTEGER
|
||||
)
|
||||
''');
|
||||
|
||||
await db.execute('''
|
||||
CREATE TABLE destinos (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
nombre TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
hash TEXT NOT NULL,
|
||||
activo INTEGER DEFAULT 1
|
||||
)
|
||||
''');
|
||||
|
||||
await db.execute('''
|
||||
CREATE TABLE bibliotecas (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
nombre TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
endpoint TEXT NOT NULL,
|
||||
h_biblioteca TEXT
|
||||
)
|
||||
''');
|
||||
|
||||
await db.execute('''
|
||||
CREATE TABLE etiquetas_cache (
|
||||
h_maestro TEXT PRIMARY KEY,
|
||||
h_global TEXT,
|
||||
mrf TEXT,
|
||||
ref TEXT,
|
||||
nombre_es TEXT,
|
||||
nombre_en TEXT,
|
||||
grupo TEXT,
|
||||
biblioteca_id INTEGER
|
||||
)
|
||||
''');
|
||||
|
||||
await db.execute('''
|
||||
CREATE TABLE packs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
nombre TEXT NOT NULL,
|
||||
icono TEXT DEFAULT '📦',
|
||||
tags TEXT NOT NULL
|
||||
)
|
||||
''');
|
||||
|
||||
await db.execute('''
|
||||
CREATE TABLE pendientes (
|
||||
hash TEXT PRIMARY KEY,
|
||||
titulo TEXT,
|
||||
contenido BLOB NOT NULL,
|
||||
intentos INTEGER DEFAULT 0,
|
||||
ultimo_intento TEXT,
|
||||
proximo_intento TEXT,
|
||||
destino_id INTEGER
|
||||
)
|
||||
''');
|
||||
|
||||
await db.execute('''
|
||||
CREATE TABLE app_state (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT
|
||||
)
|
||||
''');
|
||||
|
||||
// Insert default HST biblioteca
|
||||
await db.insert('bibliotecas', {
|
||||
'nombre': 'HST',
|
||||
'url': 'https://tzrtech.org',
|
||||
'endpoint': '/api/tags',
|
||||
'h_biblioteca': 'b7149f9e2106c566032aeb29a26e4c6cdd5f5c16b4421025c58166ee345740d1',
|
||||
});
|
||||
}
|
||||
|
||||
// App State methods
|
||||
static Future<void> setState(String key, String value) async {
|
||||
final db = await database;
|
||||
await db.insert(
|
||||
'app_state',
|
||||
{'key': key, 'value': value},
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
|
||||
static Future<String?> getState(String key) async {
|
||||
final db = await database;
|
||||
final result = await db.query(
|
||||
'app_state',
|
||||
where: 'key = ?',
|
||||
whereArgs: [key],
|
||||
);
|
||||
if (result.isEmpty) return null;
|
||||
return result.first['value'] as String?;
|
||||
}
|
||||
}
|
||||
77
lib/data/repositories/config_repository.dart
Normal file
77
lib/data/repositories/config_repository.dart
Normal file
@@ -0,0 +1,77 @@
|
||||
import '../datasources/local_database.dart';
|
||||
import '../../domain/entities/destino.dart';
|
||||
import '../../domain/entities/biblioteca.dart';
|
||||
|
||||
class ConfigRepository {
|
||||
// Destinos
|
||||
Future<List<Destino>> getDestinos() async {
|
||||
final db = await LocalDatabase.database;
|
||||
final results = await db.query('destinos');
|
||||
return results.map((m) => Destino.fromMap(m)).toList();
|
||||
}
|
||||
|
||||
Future<Destino?> getDestinoActivo() async {
|
||||
final db = await LocalDatabase.database;
|
||||
final results = await db.query(
|
||||
'destinos',
|
||||
where: 'activo = ?',
|
||||
whereArgs: [1],
|
||||
limit: 1,
|
||||
);
|
||||
if (results.isEmpty) return null;
|
||||
return Destino.fromMap(results.first);
|
||||
}
|
||||
|
||||
Future<int> insertDestino(Destino destino) async {
|
||||
final db = await LocalDatabase.database;
|
||||
return db.insert('destinos', destino.toMap());
|
||||
}
|
||||
|
||||
Future<void> updateDestino(Destino destino) async {
|
||||
final db = await LocalDatabase.database;
|
||||
await db.update(
|
||||
'destinos',
|
||||
destino.toMap(),
|
||||
where: 'id = ?',
|
||||
whereArgs: [destino.id],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> deleteDestino(int id) async {
|
||||
final db = await LocalDatabase.database;
|
||||
await db.delete('destinos', where: 'id = ?', whereArgs: [id]);
|
||||
}
|
||||
|
||||
Future<void> setDestinoActivo(int id) async {
|
||||
final db = await LocalDatabase.database;
|
||||
await db.update('destinos', {'activo': 0});
|
||||
await db.update('destinos', {'activo': 1}, where: 'id = ?', whereArgs: [id]);
|
||||
}
|
||||
|
||||
// Bibliotecas
|
||||
Future<List<Biblioteca>> getBibliotecas() async {
|
||||
final db = await LocalDatabase.database;
|
||||
final results = await db.query('bibliotecas');
|
||||
return results.map((m) => Biblioteca.fromMap(m)).toList();
|
||||
}
|
||||
|
||||
Future<int> insertBiblioteca(Biblioteca biblioteca) async {
|
||||
final db = await LocalDatabase.database;
|
||||
return db.insert('bibliotecas', biblioteca.toMap());
|
||||
}
|
||||
|
||||
Future<void> updateBiblioteca(Biblioteca biblioteca) async {
|
||||
final db = await LocalDatabase.database;
|
||||
await db.update(
|
||||
'bibliotecas',
|
||||
biblioteca.toMap(),
|
||||
where: 'id = ?',
|
||||
whereArgs: [biblioteca.id],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> deleteBiblioteca(int id) async {
|
||||
final db = await LocalDatabase.database;
|
||||
await db.delete('bibliotecas', where: 'id = ?', whereArgs: [id]);
|
||||
}
|
||||
}
|
||||
129
lib/data/repositories/contenedor_repository.dart
Normal file
129
lib/data/repositories/contenedor_repository.dart
Normal file
@@ -0,0 +1,129 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
import '../datasources/local_database.dart';
|
||||
import '../datasources/backend_api.dart';
|
||||
import '../../core/constants/app_constants.dart';
|
||||
import '../../core/errors/exceptions.dart';
|
||||
import '../../core/utils/retry_utils.dart';
|
||||
import '../../domain/entities/contenedor.dart';
|
||||
import '../../domain/entities/destino.dart';
|
||||
import '../../domain/entities/pendiente.dart';
|
||||
|
||||
class ContenedorRepository {
|
||||
final BackendApi _api = BackendApi();
|
||||
|
||||
Future<Map<String, dynamic>> enviar(Contenedor contenedor, Destino destino) async {
|
||||
try {
|
||||
final result = await _api.enviarContenedor(contenedor, destino);
|
||||
await _registrarEnvio(contenedor, destino, result);
|
||||
return result;
|
||||
} catch (e) {
|
||||
await _agregarAPendientes(contenedor, destino);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _registrarEnvio(
|
||||
Contenedor contenedor,
|
||||
Destino destino,
|
||||
Map<String, dynamic> response,
|
||||
) async {
|
||||
final db = await LocalDatabase.database;
|
||||
await db.insert('registro', {
|
||||
'hash': contenedor.hash,
|
||||
'titulo': contenedor.titulo,
|
||||
'hora_envio': DateTime.now().toIso8601String(),
|
||||
'primera_conf': response['received_at'],
|
||||
'ultima_conf': response['received_at'],
|
||||
'destino_id': destino.id,
|
||||
});
|
||||
}
|
||||
|
||||
// Pendientes
|
||||
Future<void> _agregarAPendientes(Contenedor contenedor, Destino destino) async {
|
||||
final count = await getPendientesCount();
|
||||
if (count >= AppConstants.maxPendientes) {
|
||||
throw QueueFullException();
|
||||
}
|
||||
|
||||
final db = await LocalDatabase.database;
|
||||
final contenidoJson = jsonEncode(contenedor.toJson());
|
||||
|
||||
await db.insert('pendientes', {
|
||||
'hash': contenedor.hash,
|
||||
'titulo': contenedor.titulo,
|
||||
'contenido': Uint8List.fromList(utf8.encode(contenidoJson)),
|
||||
'intentos': 1,
|
||||
'ultimo_intento': DateTime.now().toIso8601String(),
|
||||
'proximo_intento': RetryUtils.calculateNextRetry(1).toIso8601String(),
|
||||
'destino_id': destino.id,
|
||||
});
|
||||
}
|
||||
|
||||
Future<int> getPendientesCount() async {
|
||||
final db = await LocalDatabase.database;
|
||||
final result = await db.rawQuery('SELECT COUNT(*) as count FROM pendientes');
|
||||
return result.first['count'] as int;
|
||||
}
|
||||
|
||||
Future<List<Pendiente>> getPendientes() async {
|
||||
final db = await LocalDatabase.database;
|
||||
final results = await db.query('pendientes', orderBy: 'ultimo_intento DESC');
|
||||
return results.map((m) => Pendiente.fromMap(m)).toList();
|
||||
}
|
||||
|
||||
Future<void> reintentar(Pendiente pendiente, Destino destino) async {
|
||||
if (!pendiente.puedeReintentar) return;
|
||||
|
||||
final db = await LocalDatabase.database;
|
||||
final contenidoStr = utf8.decode(pendiente.contenido);
|
||||
final contenidoJson = jsonDecode(contenidoStr) as Map<String, dynamic>;
|
||||
|
||||
// Reconstruct basic contenedor for retry
|
||||
final contenedor = Contenedor(
|
||||
hash: contenidoJson['hash'] as String,
|
||||
titulo: contenidoJson['titulo'] as String?,
|
||||
descripcion: contenidoJson['descripcion'] as String?,
|
||||
etiquetas: (contenidoJson['etiquetas'] as List<dynamic>?)
|
||||
?.map((e) => e as String)
|
||||
.toList() ??
|
||||
[],
|
||||
);
|
||||
|
||||
try {
|
||||
await _api.enviarContenedor(contenedor, destino);
|
||||
await db.delete('pendientes', where: 'hash = ?', whereArgs: [pendiente.hash]);
|
||||
} catch (e) {
|
||||
final nuevoIntento = pendiente.intentos + 1;
|
||||
if (nuevoIntento >= AppConstants.maxReintentos) {
|
||||
// Keep in queue but mark as exhausted
|
||||
await db.update(
|
||||
'pendientes',
|
||||
{
|
||||
'intentos': nuevoIntento,
|
||||
'ultimo_intento': DateTime.now().toIso8601String(),
|
||||
'proximo_intento': null,
|
||||
},
|
||||
where: 'hash = ?',
|
||||
whereArgs: [pendiente.hash],
|
||||
);
|
||||
} else {
|
||||
await db.update(
|
||||
'pendientes',
|
||||
{
|
||||
'intentos': nuevoIntento,
|
||||
'ultimo_intento': DateTime.now().toIso8601String(),
|
||||
'proximo_intento': RetryUtils.calculateNextRetry(nuevoIntento).toIso8601String(),
|
||||
},
|
||||
where: 'hash = ?',
|
||||
whereArgs: [pendiente.hash],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> eliminarPendiente(String hash) async {
|
||||
final db = await LocalDatabase.database;
|
||||
await db.delete('pendientes', where: 'hash = ?', whereArgs: [hash]);
|
||||
}
|
||||
}
|
||||
97
lib/data/repositories/etiqueta_repository.dart
Normal file
97
lib/data/repositories/etiqueta_repository.dart
Normal file
@@ -0,0 +1,97 @@
|
||||
import '../datasources/local_database.dart';
|
||||
import '../datasources/biblioteca_api.dart';
|
||||
import '../../domain/entities/etiqueta.dart';
|
||||
import '../../domain/entities/biblioteca.dart';
|
||||
import '../../domain/entities/pack.dart';
|
||||
|
||||
class EtiquetaRepository {
|
||||
final BibliotecaApi _api = BibliotecaApi();
|
||||
|
||||
// Etiquetas Cache
|
||||
Future<List<Etiqueta>> getEtiquetasByBiblioteca(int bibliotecaId) async {
|
||||
final db = await LocalDatabase.database;
|
||||
final results = await db.query(
|
||||
'etiquetas_cache',
|
||||
where: 'biblioteca_id = ?',
|
||||
whereArgs: [bibliotecaId],
|
||||
);
|
||||
return results.map((m) => Etiqueta(
|
||||
hMaestro: m['h_maestro'] as String,
|
||||
hGlobal: m['h_global'] as String? ?? '',
|
||||
mrf: m['mrf'] as String?,
|
||||
ref: m['ref'] as String?,
|
||||
nombreEs: m['nombre_es'] as String?,
|
||||
nombreEn: m['nombre_en'] as String?,
|
||||
grupo: m['grupo'] as String? ?? 'default',
|
||||
bibliotecaId: bibliotecaId,
|
||||
)).toList();
|
||||
}
|
||||
|
||||
Future<void> syncBiblioteca(Biblioteca biblioteca) async {
|
||||
final etiquetas = await _api.fetchEtiquetas(biblioteca);
|
||||
final db = await LocalDatabase.database;
|
||||
|
||||
// Clear old cache
|
||||
await db.delete(
|
||||
'etiquetas_cache',
|
||||
where: 'biblioteca_id = ?',
|
||||
whereArgs: [biblioteca.id],
|
||||
);
|
||||
|
||||
// Insert new
|
||||
for (final etiqueta in etiquetas) {
|
||||
await db.insert('etiquetas_cache', {
|
||||
...etiqueta.toMap(),
|
||||
'biblioteca_id': biblioteca.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<Etiqueta?> resolveHash(String hash) async {
|
||||
final db = await LocalDatabase.database;
|
||||
final results = await db.query(
|
||||
'etiquetas_cache',
|
||||
where: 'h_maestro = ? OR h_global = ?',
|
||||
whereArgs: [hash, hash],
|
||||
);
|
||||
if (results.isEmpty) return null;
|
||||
final m = results.first;
|
||||
return Etiqueta(
|
||||
hMaestro: m['h_maestro'] as String,
|
||||
hGlobal: m['h_global'] as String? ?? '',
|
||||
mrf: m['mrf'] as String?,
|
||||
ref: m['ref'] as String?,
|
||||
nombreEs: m['nombre_es'] as String?,
|
||||
nombreEn: m['nombre_en'] as String?,
|
||||
grupo: m['grupo'] as String? ?? 'default',
|
||||
bibliotecaId: m['biblioteca_id'] as int?,
|
||||
);
|
||||
}
|
||||
|
||||
// Packs
|
||||
Future<List<Pack>> getPacks() async {
|
||||
final db = await LocalDatabase.database;
|
||||
final results = await db.query('packs');
|
||||
return results.map((m) => Pack.fromMap(m)).toList();
|
||||
}
|
||||
|
||||
Future<int> insertPack(Pack pack) async {
|
||||
final db = await LocalDatabase.database;
|
||||
return db.insert('packs', pack.toMap());
|
||||
}
|
||||
|
||||
Future<void> updatePack(Pack pack) async {
|
||||
final db = await LocalDatabase.database;
|
||||
await db.update(
|
||||
'packs',
|
||||
pack.toMap(),
|
||||
where: 'id = ?',
|
||||
whereArgs: [pack.id],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> deletePack(int id) async {
|
||||
final db = await LocalDatabase.database;
|
||||
await db.delete('packs', where: 'id = ?', whereArgs: [id]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user