Commit b6b4f348 by Diego Pérez Peña

ConVertex terminado

parents 91c85acb 42e26cb7
...@@ -8,7 +8,7 @@ plugins { ...@@ -8,7 +8,7 @@ plugins {
android { android {
namespace = "com.example.prueba_multimedia" namespace = "com.example.prueba_multimedia"
compileSdk = flutter.compileSdkVersion compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion ndkVersion = "27.0.12077973"
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_11 sourceCompatibility = JavaVersion.VERSION_11
...@@ -24,7 +24,7 @@ android { ...@@ -24,7 +24,7 @@ android {
applicationId = "com.example.prueba_multimedia" applicationId = "com.example.prueba_multimedia"
// You can update the following values to match your application needs. // You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config. // For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion minSdk = 24
targetSdk = flutter.targetSdkVersion targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode versionCode = flutter.versionCode
versionName = flutter.versionName versionName = flutter.versionName
......
...@@ -4,4 +4,8 @@ ...@@ -4,4 +4,8 @@
to allow setting breakpoints, to provide hot reload, etc. to allow setting breakpoints, to provide hot reload, etc.
--> -->
<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO"/>
</manifest> </manifest>
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:prueba_multimedia/widgets/widgets.dart';
import 'convertex_prototipo_app.dart';
void main() { void main() {
runApp(ConvertexPrototipoApp());
} }
import 'dart:io';
import 'package:ffmpeg_kit_flutter_new/return_code.dart';
import 'package:prueba_multimedia/modelo/conversor.dart';
import 'convertible.dart';
import 'formato.dart';
/// Clase que representa un archivo del dispositivo
class Archivo extends Convertible {
/// Referencia al archivo físico
final File file;
/// Metadatos del archivo
late final Future<List<Metadato>> metadatos;
/// Fotograma que convertir (solo conversión vídeo a imagen)
int? fotograma;
Archivo({required super.id, required this.file}):
super(
nombre: file.path.split('/').last.split('.').first,
formatoOriginal: Formato.fromExtension(file.path.split('.').last)!,
icon: Formato.fromExtension(file.path.split('.').last)!.tipoMultimedia.icono
)
{
metadatos = Conversor.getMetadatos(this);
}
@override
Future<ReturnCode?> convertir(String pathSalida) async {
return Conversor.convertir(this, pathSalida);
}
}
import 'dart:io';
import 'package:ffmpeg_kit_flutter_new/return_code.dart';
import 'package:flutter/material.dart';
import 'package:prueba_multimedia/modelo/archivo.dart';
import 'package:uuid/uuid.dart';
import 'convertible.dart';
import 'elemento_seleccionable.dart';
import 'formato.dart';
/// Clase que representa una carpeta cuyos elementos pueden ser convertidos
class Carpeta extends ElementoSeleccionable {
/// Referencia al directorio físico
final Directory _directory;
/// Flag para convertir recursivamente
bool _incluirSubcarpetas = false;
/// Lista de información sobre los formatos reconocidos en esta carpeta
final List<InfoFormato> _formatos = <InfoFormato>[];
/// Lista de archivos en la carpeta base
final _elementos = <Archivo>[];
/// Lista de archivos en las subcarpetas
final _elementosSubcarpetas = <Archivo>[];
/// Devuelve la referencia al directorio físico
Directory get directory => _directory;
/// Devuelve una lista inmodificable con los formatos reconocidos en esta carpeta
List<InfoFormato> get formatos => List.unmodifiable(_formatos);
/// Devuelve si el flag de las subcarpetas está activo o no
bool get incluyeSubcarpetas => _incluirSubcarpetas;
Carpeta({required super.id, required Directory directory, bool incluirSubcarpetas = false}):
_directory = directory,
_incluirSubcarpetas = incluirSubcarpetas,
super(nombre: directory.path.split('/').last, icon: const Icon(Icons.folder_outlined))
{
// Buscamos en los archivos de la carpeta base y subcarpetas los diferentes formatos reconocibles
final fsEntities = directory.listSync(recursive: false, followLinks: false);
// Guardamos los archivos de la carpeta base
for(var file in fsEntities.whereType<File>()) {
if(Formato.fromExtension(file.path.split('.').last) != null){
Archivo archivo = Archivo(id: Uuid().v1(), file: file);
_elementos.add(archivo);
final f = InfoFormato(
formato: archivo.formatoOriginal,
carpeta: this,
seleccionado: true,
subCarpeta: false
);
if(!_formatos.contains(f)){
_formatos.add(f);
}
}
}
// Guardamos los archivos de las subcarpetas
for(var directory in fsEntities.whereType<Directory>()) {
final files = directory.listSync(recursive: true, followLinks: false)
.whereType<File>();
for (var file in files) {
if(Formato.fromExtension(file.path.split('.').last) != null){
Archivo archivo = Archivo(id: Uuid().v1(), file: file);
_elementosSubcarpetas.add(archivo);
final i = InfoFormato(
formato: archivo.formatoOriginal,
carpeta: this,
seleccionado: incluirSubcarpetas,
subCarpeta: true);
if(!_formatos.contains(i)){
_formatos.add(i);
}
}
}
}
}
/// Un getter que da todos los archivos según los filtros elegidos
List<Archivo> get elementosSeleccionados {
final seleccionado = <Archivo>[];
// Primero averiguamos los formatos seleccionados
final formatosSeleccionados = <Formato, Formato>{};
final formatosSeleccionadosSubcarpetas = <Formato, Formato>{};
for (var infoFormato in _formatos) {
bool seleccionado = infoFormato.seleccionado;
bool destinoNoNulo = infoFormato.formatoDestino != null;
bool noEsSubcarpeta = !infoFormato.subCarpeta;
if (seleccionado && destinoNoNulo)
{
if (noEsSubcarpeta) {
formatosSeleccionados.putIfAbsent(
infoFormato.formatoOriginal,
() => infoFormato.formatoDestino!
);
} else if (_incluirSubcarpetas) {
formatosSeleccionadosSubcarpetas.putIfAbsent(
infoFormato.formatoOriginal,
() => infoFormato.formatoDestino!
);
}
}
}
// Devolvemos archivos que tengan formato seleccionado
for (var archivo in _elementos.whereType<Archivo>()) {
if (formatosSeleccionados.keys.contains(archivo.formatoOriginal)) {
archivo.formatoDestino = formatosSeleccionados[archivo.formatoOriginal];
seleccionado.add(archivo);
}
}
for (var archivo in _elementosSubcarpetas.whereType<Archivo>()) {
if (formatosSeleccionadosSubcarpetas.keys.contains(archivo.formatoOriginal)) {
archivo.formatoDestino = formatosSeleccionadosSubcarpetas[archivo.formatoOriginal];
seleccionado.add(archivo);
}
}
return seleccionado;
}
/// Devuelve los formatos actualmente seleccionados
List<InfoFormato> get formatosSeleccionados {
final seleccionados = <InfoFormato>[];
for (var infoFormato in _formatos) {
bool seleccionado = infoFormato.seleccionado;
bool noEsSubcarpeta = !infoFormato.subCarpeta;
if (seleccionado && (noEsSubcarpeta || _incluirSubcarpetas)) {
seleccionados.add(infoFormato);
}
}
return seleccionados;
}
/// Devuelve la información relativa a un cierto formato si la hay
InfoFormato? getInfoFormato({required Formato formato}){
for(InfoFormato i in _formatos){
if(i.formatoOriginal == formato){
return i;
}
}
return null;
}
/// Fija el formato y calidad de destino de un formato original concreto
void setFormatoDestino(Formato original, Formato? destino, Calidad? calidad){
final formatosAlt = formatos.toList();
for(InfoFormato i in formatosAlt){
if(i.formatoOriginal == original){
i.formatoDestino = destino;
i.calidadSalida = calidad;
}
}
}
/// Reacciona al cambio de inclusión de las subcarpetas
void pressIncluirSubcarpetas(){
_incluirSubcarpetas = !_incluirSubcarpetas;
}
/// Reacciona al cambio de inclusión de un formato concreto
void pressAltSeleccionado(int index){
_formatos[index].seleccionado = !_formatos[index].seleccionado;
}
}
/// Clase que engloba la información relevante a un formato dentro de una carpeta
class InfoFormato extends Convertible {
/// Carpeta que contiene esta información
final Carpeta _carpeta;
/// Flag que indica si este formato está incluido en la conversión
bool seleccionado;
/// Flag que indica si este formato solo se encuentra en subcarpetas
bool subCarpeta;
/// Devuelve la carpeta
Carpeta get carpeta => _carpeta;
InfoFormato({required Formato formato, required Carpeta carpeta,
bool? seleccionado, required bool this.subCarpeta}):
_carpeta = carpeta,
seleccionado = seleccionado ?? false,
super(
id: const Uuid().v1(),
nombre: '${carpeta.nombre} > ${formato.name}',
icon: const Icon(Icons.find_in_page_outlined),
formatoOriginal: formato
);
/// Operador de igualdad
bool operator ==(Object other) =>
other is InfoFormato &&
other.runtimeType == runtimeType &&
other.formatoOriginal == formatoOriginal;
/// Devuelve un hash sobre este objeto
@override
int get hashCode => Object.hash(formatoOriginal, formatoDestino, calidadSalida, _carpeta, seleccionado, subCarpeta);
@override
Future<ReturnCode?> convertir(String _) {
throw UnimplementedError("Esta función no debería de llamarse nunca");
}
}
\ No newline at end of file
import 'dart:io';
import 'package:ffmpeg_kit_flutter_new/ffmpeg_kit.dart';
import 'package:ffmpeg_kit_flutter_new/ffmpeg_session.dart';
import 'package:ffmpeg_kit_flutter_new/ffprobe_kit.dart';
import 'package:ffmpeg_kit_flutter_new/return_code.dart';
import 'package:path_provider/path_provider.dart';
import 'package:prueba_multimedia/modelo/modelo.dart';
abstract class Conversor {
/// Se llama una vez el usuario pulsa convertir
static Future<ReturnCode?> convertir(Archivo archivo, String pathSalida) async {
String path = archivo.file.path;
ReturnCode? returnCode;
String nuevoPath = "$pathSalida/${archivo.nombre}.${archivo.formatoDestino!.name}";
bool conversionVideoAOtro =
archivo.formatoOriginal.tipoMultimedia == TipoMultimedia.video
&& archivo.formatoDestino?.tipoMultimedia != TipoMultimedia.video;
if (!conversionVideoAOtro) {
returnCode = await FFmpegKit.execute("-i $path $nuevoPath")
.then((session) => session.getReturnCode());
} else {
returnCode = await Conversor.getFotograma(archivo, archivo.fotograma!, nuevoPath);
}
return returnCode;
}
// https://www.ffmpeg.org/ffmpeg-formats.html#ffmetadata
/// Devuelve los metadatos del archivo o null si ha habido un problema
static Future<List<Metadato>> getMetadatos(Archivo archivo) async {
// Creamos archivo metadatos
var archivoSalida = "${archivo.nombre}.ffmeta";
var cacheDirectory = await getApplicationCacheDirectory();
var pathArchivoSalida = "${cacheDirectory.path}/$archivoSalida";
var comando = "-i ${archivo.file.path} -f ffmetadata $pathArchivoSalida";
FFmpegSession session = await FFmpegKit.execute(comando);
// Leemos archivo metadatos
var metadatos = <Metadato>[];
var returnCode = await session.getReturnCode();
if (returnCode == null || returnCode.getValue() != 0) {
return metadatos;
}
var archivoMetadatos = File(pathArchivoSalida);
var lineas = await archivoMetadatos.readAsLines();
archivoMetadatos.delete(recursive: false);
// Procesar metadatos
for (var linea in lineas) {
// Vacio, comentario o indicar sección
if (linea.isEmpty || linea.startsWith(RegExp("[;#[]"))) {
continue;
}
var campos = linea.split("=");
try {
metadatos.add(
Metadato(
info: MetadatoInfo.values.byName(campos[0]),
valor: campos[1]
));
} catch (e) {
print("No se ha podido añadir un metadato ${campos[0]}");
}
}
return metadatos;
}
static Future<int?> getNumFotogramas(Archivo archivo) async {
String? sNumFotogramas = await (FFprobeKit.execute(
'-v error -select_streams v:0 -count_frames -show_entries stream=nb_read_frames -of csv=p=0 -i ${archivo.file.path}'
).then((session) => session.getOutput()));
return sNumFotogramas != null
? int.tryParse(sNumFotogramas)
: null;
}
static Future<double?> getDuracionVideo(Archivo archivo) async {
String? sDuracion = await FFprobeKit.execute(
'-v 0 -of csv="p=0" -select_streams V:0 -show_entries stream=duration -i ${archivo.file.path}'
).then((session) => session.getOutput());
return sDuracion != null
? double.tryParse(sDuracion)
: null;
}
/// Extrae el fotograma especificado con un timestamp de un video.
/// Devuelve un archivo que es una imagen jpg
// Podría hacer que los parámetros fueran números pero se quedó así
static Future<ReturnCode?> getFotograma(Archivo video, int fotograma, [String? salida]) async {
// Cálculo de la timestamp del fotograma a extraer
int? numFotogramas = await Conversor.getNumFotogramas(video);
double? duracion = await Conversor.getDuracionVideo(video);
// Los timestamp son de la forma 00:00:00 y podemos tener segundos con decimales
final double timeStamp = (fotograma/numFotogramas!)*duracion!;
String hour, min, sec;
sec = ((timeStamp % 60.0) % 60.0).toString();
while(sec.indexOf('.') < 2){
sec = '0$sec';
}
min = ((timeStamp / 60).floor() % 60).toString();
min = min.padLeft(2,'0');
hour = (timeStamp / 3600).floor().toString();
hour = hour.padLeft(2,'0');
// Extraer el fotograma
final directory = await getApplicationSupportDirectory();
String pathSalida = salida ?? '${directory.absolute.path}${Platform.pathSeparator}fotograma.${video.formatoDestino?.extension}';
// Este comando extrae un frame del timestamp dado
// La opción -y acepta hacer overwrite
return await FFmpegKit.execute(
'-y -i ${video.file.path} -ss $hour:$min:$sec -frames:v 1 $pathSalida'
).then((session) => session.getReturnCode());
}
}
\ No newline at end of file
import 'elemento_seleccionable.dart';
import 'formato.dart';
import 'package:ffmpeg_kit_flutter_new/return_code.dart';
abstract class Convertible extends ElementoSeleccionable{
/// Formato original del elemento
final Formato _formatoOriginal;
/// Formato del fichero de salida
Formato? formatoDestino;
/// Calidad del fichero de salida
Calidad? calidadSalida;
Formato get formatoOriginal => _formatoOriginal;
Convertible({required super.id, required super.nombre, required super.icon,
required Formato formatoOriginal}):
_formatoOriginal = formatoOriginal;
Future<ReturnCode?> convertir(String pathSalida);
}
\ No newline at end of file
import 'package:flutter/material.dart';
/// Clase que representa cualquier elemento que se pueda seleccionar para la conversión
abstract class ElementoSeleccionable {
/// Identificador único del elemento
final String _id;
/// Nombre del elemento
final String _nombre;
/// Icono que representa al elemento
final Icon _icono;
/// Devuelve el identificador único del elemento
String get id => _id;
/// Devuelve el nombre del elemento
String get nombre => _nombre;
/// Devuelve el icono que representa al elemento
Icon get icono => _icono;
ElementoSeleccionable({required id,
required String nombre,
required Icon icon}):
_id = id, _nombre = nombre, _icono = icon;
}
\ No newline at end of file
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:ffmpeg_kit_flutter_new/return_code.dart';
import 'package:flutter/material.dart';
import 'package:path_provider/path_provider.dart';
import 'package:uuid/uuid.dart';
import 'package:prueba_multimedia/modelo/modelo.dart';
/// Clase que representa un enlace a un archivo alojado en la web
class Enlace extends Convertible {
/// URL al archivo en cuestión
final String _url;
/// Archivo que se descargadrá y que contiene el archivo a convertir
late final Future<File> _file;
/// Si el archivo se está descargando
bool _descargado = false;
/// Nombre del archivo
String? _nombreArchivo;
/// Devuelve la URL al archivo en cuestión
String get url => _url;
/// Devuelve el nombre del archivo
String? get nombreArchivo => _nombreArchivo;
/// Devuelve si el archivo se está descargando
bool get descargado => _descargado;
Enlace({required String super.id, required String direccion}):
_url = direccion, super(
nombre: direccion.length < 33
? direccion
: "${direccion.substring(0, 33)}...",
formatoOriginal: Formato.fromExtension(direccion.split('.').last)!,
icon: const Icon(Icons.link)
)
{
_file = _descargar(_url);
}
/// Función para descargar el archivo desde internet
Future<File> _descargar(String direccion) async {
// Descargamos los datos
final dio = Dio();
final response = await dio.get(_url,
options: Options(
responseType: ResponseType.bytes
)
);
// Miramos el nombre del archivo en la respuesta o lo generamos aleatoriamente
String? nombre = response.headers.map['Content-Disposition']?.first;
if (nombre == null) {
nombre = const Uuid().v1().toString();
} else {
nombre = nombre.split('=').last;
// filename encoding, quitamos los "..."
if (nombre.startsWith('"')) {
nombre = nombre.substring(1, nombre.length-1);
}
// filename* encoding, habría que parsear en UTF8
else {
nombre = nombre.substring(7);
}
}
_nombreArchivo = nombre;
// Escribimos los datos en archivo temporal
Directory temp = await getTemporaryDirectory();
final File file = await File("${temp.path}/$nombre.${formatoOriginal.name}").create();
final raf = file.openSync(mode: FileMode.write);
raf.writeFromSync(response.data);
await raf.close();
_descargado = true;
return file;
}
/// Convierte este enlace al formato deseado
@override
Future<ReturnCode?> convertir(String pathSalida) async {
// Eliminamos el archivo temporal después de la conversión
Archivo archivo = Archivo(id: id, file: await _file);
archivo.formatoDestino = formatoDestino;
final result = Conversor.convertir(archivo, pathSalida);
result.then((_) async => (await _file).delete());
return result;
}
}
enum RedSocial{
FACEBOOK,
TWITTER,
INSTAGRAM
}
\ No newline at end of file
import 'package:flutter/material.dart';
/// Enumerado que representa a los diferentes formatos que puede leer y
/// a los que puede convertir la aplicación
enum Formato {
png(['png'], [], 'Portable Network Graphics', TipoMultimedia.imagen, Clasificacion.calidad, [],
'Formato gráfico basado en un algoritmo de compresión sin pérdida para bitmaps no sujeto a patentes. Fue desarrollado en buena parte para solventar las deficiencias del formato GIF y permite almacenar imágenes con una mayor profundidad de contraste y otros datos importantes.'
),
jpg(['jpeg','jpg','jpe'], Calidad.values, 'Joint Photographic Experts Group', TipoMultimedia.imagen, Clasificacion.ligero, [],
'A pesar de ser un método de compresión, es a menudo considerado como un formato de archivo. Es el formato de imagen más común, utilizado por las cámaras fotográficas digitales y otros dispositivos de captura de imagen'
),
tif(['tif','tiff'], [], 'Tagged Image File Format', TipoMultimedia.imagen, Clasificacion.calidad, [],
'Un formato de archivo informático para almacenar imágenes de mapa de bits. Es prevalente en la industria gráfica y en la fotografía profesional por su versatilidad y compresión no destructiva.'
),
webp(['webp'], Calidad.values, 'Web Picture', TipoMultimedia.imagen, Clasificacion.versatil, [],
'Formato desarrollado por Google, basándose en tecnología de On2 Technologies. Es un formato gráfico en forma de contenedor, que sustenta tanto compresión con pérdida como sin ella.'
),
mp3(['mp3'], [], 'MPEG-1 Layer III', TipoMultimedia.audio, Clasificacion.calidad,
[ MetadatoInfo.title, MetadatoInfo.artist, MetadatoInfo.album, MetadatoInfo.genre,
MetadatoInfo.composer, MetadatoInfo.track, MetadatoInfo.language],
'Un formato de compresión de audio digital que usa un algoritmo con pérdida para conseguir un menor tamaño de archivo. Es un formato de audio común utilizado para música tanto en computadoras como en reproductores de audio portátil.'
),
oga(['oga','opus'], [], 'Xiph.org Ogg Vorbis', TipoMultimedia.audio, Clasificacion.versatil, [],
'Un formato contenedor libre y abierto, desarrollado y mantenido por la Fundación Xiph.Org que no está restringido por las patentes de software, y está diseñado para proporcionar una difusión de flujo eficiente y manipulación de multimedios digitales de alta calidad.'
),
wav(['wav','wave'], [], 'Waveform Audio File Format', TipoMultimedia.audio, Clasificacion.calidad, [],
'Un formato de audio digital con o sin compresión de datos desarrollado por Microsoft e IBM que se utiliza para almacenar flujos digitales de audio en el PC, mono y estéreo a diversas resoluciones y velocidades de muestreo.'
),
flac(['flac'], [], 'Free Lossless Audio Codec', TipoMultimedia.audio, Clasificacion.calidad, [],
'Es un formato abierto con licencia libre de derechos de autor y una implementación de referencia la cual es software libre. FLAC cuenta con soporte para etiquetado de metadatos, inclusión de la portada del álbum, y la búsqueda rápida.'
),
mp4(['mp4','m4a','m4p','m4v','m4b','m4r'], [], 'MPEG-4 Parte 14', TipoMultimedia.video, Clasificacion.versatil,
[ MetadatoInfo.title, MetadatoInfo.author, MetadatoInfo.album, MetadatoInfo.year,
MetadatoInfo.genre, MetadatoInfo.composer, MetadatoInfo.track,
MetadatoInfo.description, MetadatoInfo.comment],
'Un formato contenedor especificado como parte del estándar internacional MPEG-4 de ISO/IEC. Es utilizado para almacenar los formatos audiovisuales especificados por ISO/IEC y el grupo MPEG (Moving Picture Experts Group) al igual que otros formatos audiovisuales disponibles.'
),
mkv(['mkv','mk3d','mka','mks'], [], 'Matroshka', TipoMultimedia.video, Clasificacion.calidad,
[ MetadatoInfo.title, MetadatoInfo.description, MetadatoInfo.language ],
'Un formato contenedor abierto que puede almacenar una cantidad muy grande de vídeo, audio, imagen o pistas de subtítulos dentro de un solo archivo. Su finalidad es la de servir como formato universal para el almacenamiento de contenidos audiovisuales y multimedia, como películas o programas de televisión, imágenes y textos.'
),
ogv(['ogv', 'ogm'], [], 'Xiph.org Ogg Theora', TipoMultimedia.video, Clasificacion.ligero, [],
'Un códec de vídeo libre que está siendo desarrollado por la Fundación Xiph.Org, como parte de su proyecto Ogg. Basado en el códec VP3 donado por On2 Technologies, Xiph.Org lo ha refinado y extendido dándole el mismo alcance futuro para mejoras en el codificador como el que posee el códec de audio Vorbis.'
);
/// Lista de extensiones asociadas al formato
final List<String> listExtensiones;
/// Lista de posibles calidades del formato
final List<Calidad> listCalidades;
/// Nombre completo del formato
final String nombre;
/// Tipo de fichero multimedia
final TipoMultimedia tipoMultimedia;
/// Clasificación del formato
final Clasificacion clasificacion;
/// Metadatos que puede contener el formato
final List<MetadatoInfo> metadatos;
/// Breve descripción del formato
final String descripcion;
/// Devuelve la extensión principal de este formato
String get extension => name;
const Formato(this.listExtensiones, this.listCalidades, this.nombre,
this.tipoMultimedia, this.clasificacion, this.metadatos, this.descripcion);
/// Devuelve aquellos formatos de TipoMultimedia que no sean excepcion
static List<Formato> listadoFormatos({required TipoMultimedia tipo, Formato? excepcion}){
final toRet = <Formato>[];
for(var formato in Formato.values){
if(formato.tipoMultimedia == tipo && formato != excepcion) {
toRet.add(formato);
}
}
return toRet;
}
/// Devuelve el formato de un archivo. Si la extensión no es válida, devuelve nulo
static Formato? fromExtension(String extension){
// COMPROBAR SI UN ARCHIVO OGG CONTIENE VÍDEO O SOLO AUDIO
// ffprobe -v error -select_streams v:0 -show_entries stream=codec_type -of csv=p=0 [ARCHIVO].ogg
// Outputs either video or no output at all.
// https://stackoverflow.com/questions/56397732/how-can-i-know-a-certain-file-is-a-video-file
for(Formato f in Formato.values) {
for(String ext in f.listExtensiones){
if(ext == extension){
return f;
}
}
}
return null;
}
}
/// Enumerado de los diferentes tipos de fichero multimedia que puede contener un formato
enum TipoMultimedia {
video("Vídeo", Icon(Icons.movie_creation_outlined)),
audio("Audio", Icon(Icons.music_note_outlined)),
imagen("Imagen", Icon(Icons.image_outlined));
/// Nombre mostrado del tipo de fichero multimedia
final String nombre;
/// Icono que representa al tipo de fichero
final Icon icono;
const TipoMultimedia(this.nombre, this.icono);
}
/// Enumerado de las diferentes clasificaciones que puede tener un formato
enum Clasificacion {
calidad("Calidad", Icons.high_quality_outlined),
ligero("Ligero", Icons.electric_bolt_outlined),
versatil("Versátil", Icons.auto_mode_outlined);
/// Nombre mostrado de la clasificación
final String nombre;
/// Icono que representa a la clasificación
final IconData icono;
const Clasificacion(this.nombre, this.icono);
}
/// Enumerado que representa las diferentes calidades a las que se puede convertir un archivo
enum Calidad {
baja("Baja"), media("Media"), alta("Alta"), muyAlta("Muy Alta");
/// Nombre mostrado de la calidad
final String texto;
const Calidad(this.texto);
}
/// Enumerado de los tipos de metadato soportados
enum MetadatoInfo {
title("Título"),
artist("Artista"),
author("Autor"),
album("Álbum"),
genre("Género"),
composer("Compositor"),
track("Número de pista", true),
language("Idioma"),
year("Año", true),
description("Descripción"),
comment("Comentario"),
rating("Valoración", true),
encoder("Encoder", false);
/// Nombre mostrado del metadato
final String nombreMostrado;
/// Flag de si el metadato es únicamente numérico o no
final bool esNumerico;
const MetadatoInfo(this.nombreMostrado, [this.esNumerico = false]);
}
/// Clase que representa un metadato que puede tener un archivo
class Metadato {
/// Tipo de metadato
final MetadatoInfo info;
/// Valor actual de este metadato
String valor;
Metadato({required this.info, required this.valor});
}
\ No newline at end of file
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:prueba_multimedia/modelo/enlace.dart';
import 'package:uuid/uuid.dart';
import 'carpeta.dart';
import 'elemento_seleccionable.dart';
import 'archivo.dart';
import 'formato.dart';
/// Clase que contiene la lista de seleccionables elegidos por el usuario
class ListaSeleccionables extends ChangeNotifier {
/// Lista de seleccionables
final _seleccionables = <ElementoSeleccionable>[];
/// Flag que indica si actualmente se está convirtiendo
bool _convirtiendo = false;
/// Número del 0 al 100 que indica el progreso en la conversión
int _progress = 0;
/// Devuelve una copia inmutable de la lista de seleccionables
List<ElementoSeleccionable> get seleccionables => List.unmodifiable(_seleccionables);
/// Devuelve si se está convirtiendo
bool get convirtiendo => _convirtiendo;
/// Devuelve el progreso en la conversión
int get progress => _progress;
/// Borra un elemento de la lista
void borraSeleccionable(int indice) {
_seleccionables.removeAt(indice);
notifyListeners();
}
/// Actualiza un elemento de la lista
void actualizaSeleccionable(int indice, ElementoSeleccionable elemento){
_seleccionables[indice] = elemento;
notifyListeners();
}
/// Agrega un archivo a la lista
bool addArchivo(File file) {
if(Formato.fromExtension(file.path.split('.').last) != null){
_seleccionables.add(Archivo(id: const Uuid().v1(),
file: file));
notifyListeners();
return true;
}
return false;
}
/// Agrega una carpeta a la lista
bool addCarpeta(Directory directory, bool incluirSubcarpetasPorDefecto){
final newCarpeta = Carpeta(
id: const Uuid().v1(),
directory: directory,
incluirSubcarpetas: incluirSubcarpetasPorDefecto
);
if(newCarpeta.formatos.isNotEmpty){
_seleccionables.add(
newCarpeta
);
notifyListeners();
return true;
}
return false;
}
/// Agrega un enlace (archivo para descargar) a ala lista
bool addEnlace(String url) {
if(Formato.fromExtension(url.split('.').last) != null){
_seleccionables.add( Enlace(
id: const Uuid().v1(),
direccion: url
));
notifyListeners();
return true;
}
return false;
}
/// Reinserta un elemento anteriormente eliminado de la lista
void reinsertar(int index, ElementoSeleccionable element){
if(index > _seleccionables.length){
_seleccionables.add(element);
}
else{
_seleccionables.insert(index, element);
}
notifyListeners();
}
/// Inicia la conversión
void iniciarConversion() {
_convirtiendo = true;
notifyListeners();
}
/// Finaliza la conversión
void finalizarConversion() {
_convirtiendo = false;
_progress = 0;
notifyListeners();
}
/// Actualiza el progreso de conversión
void actualizarProgreso(int initialSize) {
_progress = ((1 - (_seleccionables.length / initialSize))*100).floor();
notifyListeners();
}
}
\ No newline at end of file
export 'archivo.dart';
export 'carpeta.dart';
export 'conversor.dart';
export 'convertible.dart';
export 'elemento_seleccionable.dart';
export 'enlace.dart';
export 'formato.dart';
export 'lista_seleccionables.dart';
export 'perfil.dart';
export 'provider_ajustes.dart';
\ No newline at end of file
import 'formato.dart';
/// Enumerado que representa los diferentes perfiles a los cuales puede convertir el usuario
enum Perfil {
altaCalidad('Alta calidad', Formato.png, Formato.wav, Formato.mkv, Calidad.muyAlta),
compartir('Para compartir', Formato.jpg, Formato.mp3, Formato.mp4, Calidad.baja),
whatsapp('Uso libre', Formato.webp, Formato.flac, Formato.mkv, Calidad.media);
/// Nombre mostrado del perfil
final String nombre;
/// Extensión de imagen
final Formato? extensionImagen;
/// Extensión de audio
final Formato? extensionAudio;
/// Extensión de vídeo
final Formato? extensionVideo;
/// Calidad elegida
final Calidad calidad;
const Perfil(this.nombre, this.extensionImagen, this.extensionAudio,
this.extensionVideo, this.calidad);
}
\ No newline at end of file
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Clase que contiene los ajustes seleccionados por el usuario
class ProviderAjustes extends ChangeNotifier {
/// Índice del modo de conversión
int _modoConversion = 1;
/// Índice de la inclusión de subcarpetas
int _incluirSubcarpetas = 1;
/// Carpeta de salida. Si está vacía, se pregunta al convertir
String _carpetaSalida = "";
/// Flag que indica si se están cargando los valores anteriores
bool _cargando = true;
/// Clave del modo de conversión
static const modoConversionKey = 'modoConversion';
/// Clave de la inclusión de subcarpetas
static const incluirSubcarpetasKey = 'incluirSubcarpetas';
/// Clave de la carpeta de salida
static const carpetaSalidaKey = 'carpetaSalida';
/// Devuelve el modo de conversión
int get modoConversion => _modoConversion;
/// Devuelve si se incluyen subcarpetas por defecto
int get incluirSubcarpetas => _incluirSubcarpetas;
/// Devuelve la carpeta de salida actualmente seleccionada
String get carpetaSalida => _carpetaSalida;
/// Devuelve si se están cargando los valores anteriores
bool get cargando => _cargando;
/// Devuelve si se debe expandir el botón de Convertir
bool get expandConvert => _cargando || _modoConversion == 0;
ProviderAjustes() {
// Al crear el provider, se cargan inmediatamente los valores anteriores
_cargarValoresAnteriores();
}
/// Carga los valores anteriormente seleccionados si se puede
Future<void> _cargarValoresAnteriores() async {
final prefs = await SharedPreferences.getInstance();
_modoConversion = await prefs.getInt(modoConversionKey) ?? 1;
_incluirSubcarpetas = await prefs.getInt(incluirSubcarpetasKey) ?? 1;
_carpetaSalida = await prefs.getString(carpetaSalidaKey) ?? '';
// Comprobamos si la carpeta existe
if(_carpetaSalida.isNotEmpty){
final directory = Directory(carpetaSalida);
if(!(await directory.exists())){
_carpetaSalida = '';
}
}
_cargando = false;
notifyListeners();
}
/// Fija un índice para el modo de conversión
void setModoConversion(int valor) {
_modoConversion = valor;
notifyListeners();
_actualizarModoConversion();
}
/// Fija un índice para la inclusión de subcarpetas
void setIncluirSubcarpetas(int valor) {
_incluirSubcarpetas = valor;
notifyListeners();
_actualizarIncluirSubcarpetasn();
}
/// Fija la carpeta de salida
void setCarpetaSalida(String valor) {
_carpetaSalida = valor;
notifyListeners();
_actualizarCarpetaSalida();
}
/// Guarda el valore actualmente seleccionado para el modo de conversión
Future<void> _actualizarModoConversion() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setInt(modoConversionKey, _modoConversion);
}
/// Guarda el valore actualmente seleccionado para la inclusión de subcarpetas
Future<void> _actualizarIncluirSubcarpetasn() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setInt(incluirSubcarpetasKey, _incluirSubcarpetas);
}
/// Guarda el valore actualmente seleccionado para la carpeta de salida
Future<void> _actualizarCarpetaSalida() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(carpetaSalidaKey, _carpetaSalida);
}
}
\ No newline at end of file
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:prueba_multimedia/modelo/provider_ajustes.dart';
import 'package:prueba_multimedia/widgets/action_button.dart';
/// Página de Ajustes de la aplicación
class PaginaAjustes extends StatefulWidget {
/// Las diferentes opciones del modo de conversión
static const opcionesModoConversion = <String>[
"Preguntar siempre", "Copiar", "Comprimir"
];
/// Las diferentes opciones de la inclusión de subcarpetas
static const opcionesIncluirSubcarpetas = <String>[
"Sí", "No"
];
/// Las diferentes opciones de la selección de carpeta de salida
// Si hemos elegido una carpeta entonces se tendría que mostrar
static const opcionesCarpetaSalida = <String>[
"Elegir", "Preguntar siempre"
];
final ProviderAjustes provider;
const PaginaAjustes({super.key, required this.provider});
@override
State<PaginaAjustes> createState() => _PaginaAjustesState();
}
class _PaginaAjustesState extends State<PaginaAjustes> {
/// Índices de los valores seleccionados de cada opcion
int modoConversion = 0;
int incluirSubcarpeta = 0;
int carpetaSalidaIndex = 0;
String carpetaSalida = '';
@override
void initState() {
if(!widget.provider.cargando){
setState(() {
modoConversion = widget.provider.modoConversion;
incluirSubcarpeta = widget.provider.incluirSubcarpetas;
carpetaSalida = widget.provider.carpetaSalida;
carpetaSalidaIndex = (widget.provider.carpetaSalida.isEmpty)? 1 : 0;
});
}
super.initState();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
leading: BackButton(
color: Colors.white,
),
title: Text(
"Ajustes",
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: Colors.white
),
),
backgroundColor: Theme.of(context).primaryColor,
),
body: ListView(
children: [
InkWell(
onTap: () {
var callbacks = <VoidCallback>[];
PaginaAjustes.opcionesModoConversion.asMap().forEach(
(index, opcion) => callbacks.add(() {
setState(() {modoConversion = index;});
widget.provider.setModoConversion(index);
Navigator.pop(context);
})
);
mostrarDialogoOpciones(
opciones: PaginaAjustes.opcionesModoConversion,
callbacks: callbacks,
seleccionado: modoConversion
);
},
child: ListTile(
title: const Text("Modo de conversión"),
subtitle: Text(PaginaAjustes.opcionesModoConversion[modoConversion]),
),
),
InkWell(
onTap: () {
var callbacks = <VoidCallback>[];
PaginaAjustes.opcionesIncluirSubcarpetas.asMap().forEach(
(index, opcion) => callbacks.add(() {
setState(() {incluirSubcarpeta = index;});
widget.provider.setIncluirSubcarpetas(index);
Navigator.pop(context);
})
);
mostrarDialogoOpciones(
opciones: PaginaAjustes.opcionesIncluirSubcarpetas,
callbacks: callbacks,
seleccionado: incluirSubcarpeta
);
},
child: ListTile(
title: const Text("Incluir subcarpetas por defecto"),
subtitle: Text(PaginaAjustes.opcionesIncluirSubcarpetas[incluirSubcarpeta]),
),
),
InkWell(
onTap: () {
var callbacks = <VoidCallback>[];
PaginaAjustes.opcionesCarpetaSalida.asMap().forEach(
(index, opcion) => (index == 0)?
callbacks.add(() => _elegirCarpetaSalida(context, index, 1)) :
callbacks.add(() {
setState(() {
carpetaSalida = '';
carpetaSalidaIndex = index;
});
widget.provider.setCarpetaSalida('');
Navigator.pop(context);
})
);
mostrarDialogoOpciones(
opciones: PaginaAjustes.opcionesCarpetaSalida,
callbacks: callbacks,
seleccionado: carpetaSalidaIndex
);
},
child: ListTile(
title: const Text("Carpeta de salida"),
subtitle: Text(
(carpetaSalida.isEmpty)?
PaginaAjustes.opcionesCarpetaSalida[carpetaSalidaIndex] :
carpetaSalida
),
),
),
InkWell(
onTap: () {
showDialog(context: context, builder: (context) {
TextTheme textTheme = Theme.of(context).textTheme;
return SimpleDialog(
children: [
Column(
children: [
Text(
"ConVertex",
style: textTheme.titleLarge,
),
const SizedBox(height: 16.0,),
Text(
"Desarrollado por:",
style: textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.bold
),
),
const SizedBox(height: 8.0,),
Text(
"Diego Pérez Peña",
style: textTheme.bodyLarge,
),
const SizedBox(height: 4.0,),
Text(
"Rafael Castillo Passols",
style: textTheme.bodyLarge,
),
],
)
],
);
});
},
child: ListTile(
title: const Text("Acerca de..."),
),
),
],
),
);
}
/// Muestra un diálogo con las opciones disponibles para un ajuste
Future<T?> mostrarDialogoOpciones<T>({
required List<String> opciones,
required List<VoidCallback> callbacks,
required int seleccionado})
{
var listaOpciones = <Widget>[];
opciones.asMap().forEach((index, opcion) {
listaOpciones.add(
// Me gustaria que al pulsar en cualquier sitio se hiciese splash
// solo en el Radio pero no se como todavia
GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: callbacks[index],
child: Row(
children: [
Radio<int>(
value: index,
groupValue: seleccionado,
onChanged: (int? changed) {callbacks[index]();}
),
Text(opcion)
],
),
)
);
});
return showDialog(context: context,
builder: (context) => SimpleDialog(children: listaOpciones,)
);
}
/// Abre el diálogo para elegir una carpeta de salida
Future<void> _elegirCarpetaSalida(BuildContext context, int indice, int indicePorDefecto) async {
if(await ActionButton.comprobacionPermisoArchivos(context)){
FilePicker.platform.getDirectoryPath().then((path) {
if (path != null) {
var directory = Directory(path);
setState(() {
carpetaSalida = directory.absolute.path;
carpetaSalidaIndex = indice;
});
widget.provider.setCarpetaSalida(directory.absolute.path);
}
else {
setState(() {
carpetaSalida = '';
carpetaSalidaIndex = indicePorDefecto;
});
widget.provider.setCarpetaSalida('');
}
});
}
else {
setState(() {
carpetaSalida = '';
carpetaSalidaIndex = indicePorDefecto;
});
widget.provider.setCarpetaSalida('');
}
Navigator.pop(context);
}
}
import 'package:flutter/material.dart';
import 'package:prueba_multimedia/modelo/modelo.dart';
import 'package:prueba_multimedia/paginas/paginas.dart';
/// Página de configuración para archivos, enlaces y formatos
class PaginaConfiguracion extends StatefulWidget {
final ListaSeleccionables _lista;
/// Índice del seleccionable en la lista
final int _indice;
final Convertible _elementoAsociado;
/// Carpeta a la que pertenece el formato en su caso
final Carpeta? _carpeta;
const PaginaConfiguracion({
super.key,
required ListaSeleccionables lista,
required int indice,
required Convertible elemento,
Carpeta? carpeta
}): _lista = lista, _indice = indice,
_elementoAsociado = elemento, _carpeta = carpeta;
@override
State<PaginaConfiguracion> createState() => _PaginaConfiguracionState();
}
class _PaginaConfiguracionState extends State<PaginaConfiguracion> {
int _categoriaActiva = 0;
final _paginas = <Widget>[];
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: RichText(
maxLines: 1,
overflow: TextOverflow.ellipsis,
text: TextSpan(
children: [
WidgetSpan(
child: widget._elementoAsociado.icono
),
TextSpan(
text: " ${widget._elementoAsociado.nombre}",
style: Theme.of(context).textTheme.titleLarge,
)
]
)
),
),
body: _construirCuerpo(),
bottomNavigationBar: widget._elementoAsociado is Archivo
? BottomNavigationBar(
currentIndex: _categoriaActiva,
onTap: (int indice) => setState(() { _categoriaActiva = indice; }),
items: _construirElementosBarraNavegacion()
)
: null
);
}
/// Construye el cuerpo de la página
Widget _construirCuerpo() {
if (widget._carpeta != null) {
InfoFormato infoFormato = widget._elementoAsociado as InfoFormato;
return PaginaConversion.carpeta(
formatoOriginal: infoFormato.formatoOriginal,
indiceArchivo: widget._indice,
carpeta: widget._carpeta!,
infoFormato: infoFormato,
lista: widget._lista
);
}
Formato formatoOriginal = widget._elementoAsociado.formatoOriginal;
bool esFormatoVideo = formatoOriginal.tipoMultimedia == TipoMultimedia.video;
_paginas.add(
PaginaConversion.convertible(
lista: widget._lista,
indiceArchivo: widget._indice,
elemento: widget._elementoAsociado,
formatoOriginal: widget._elementoAsociado.formatoOriginal
)
);
if (widget._elementoAsociado is Archivo) {
var archivo = widget._elementoAsociado as Archivo;
if (esFormatoVideo) {
_paginas.add(PaginaFotograma(
lista: widget._lista,
archivo: archivo,
indice: widget._indice,
));
}
_paginas.add(
FutureBuilder(
future: archivo.metadatos,
builder: (context, snapshot) {
return snapshot.hasData
? PaginaMetadatos(
lista: widget._lista,
indice: widget._indice,
archivo: archivo,
metadatos: snapshot.data!,
)
: const CircularProgressIndicator();
}
),
);
}
return _paginas[_categoriaActiva];
}
/// Construye los elementos en la barra de navegación inferior
List<BottomNavigationBarItem> _construirElementosBarraNavegacion(){
Formato formatoOriginal = widget._elementoAsociado.formatoOriginal;
bool esFormatoVideo = formatoOriginal.tipoMultimedia == TipoMultimedia.video;
final items = <BottomNavigationBarItem>[
const BottomNavigationBarItem(
icon: Icon(Icons.sync),
label: 'Formato'
),
];
if (widget._elementoAsociado is Archivo) {
if (esFormatoVideo) {
items.add(
const BottomNavigationBarItem(
icon: Icon(Icons.local_movies),
label: 'Fotograma'
)
);
}
items.add(
const BottomNavigationBarItem(
icon: Icon(Icons.format_list_bulleted),
label: 'Metadatos'
),
);
}
return items;
}
}
import 'package:flutter/material.dart';
import 'package:prueba_multimedia/modelo/modelo.dart';
import 'package:prueba_multimedia/paginas/paginas.dart';
/// Página de configuración para carpetas
class PaginaConfiguracionCarpeta extends StatefulWidget {
final ListaSeleccionables _lista;
/// Lista de formatos de la carpeta
final List<InfoFormato> _formatosCarpeta;
/// Índice de la carpeta en la lista
final int _indice;
final Carpeta _carpeta;
PaginaConfiguracionCarpeta({
super.key,
required ListaSeleccionables lista,
required int indice,
required Carpeta carpeta
}):
_lista = lista,
_indice = indice,
_carpeta = carpeta,
_formatosCarpeta = carpeta.formatos;
@override
State<PaginaConfiguracionCarpeta> createState() => _PaginaConfiguracionCarpetaState();
}
class _PaginaConfiguracionCarpetaState extends State<PaginaConfiguracionCarpeta> {
final Map<TipoMultimedia, List<Formato>> _formatos = {};
final Map<TipoMultimedia, bool?> _allOfType = {};
final Map<Formato, bool> _seleccionados = {};
late bool _incluirSubcarpetas;
@override
void initState() {
super.initState();
_incluirSubcarpetas = widget._carpeta.incluyeSubcarpetas;
for(TipoMultimedia t in TipoMultimedia.values){
_formatos[t] = [];
}
for(InfoFormato i in widget._formatosCarpeta){
_formatos[i.formatoOriginal.tipoMultimedia]?.add(i.formatoOriginal);
_seleccionados[i.formatoOriginal] = i.seleccionado;
}
for(TipoMultimedia t in TipoMultimedia.values){
_allOfType[t] = _actualizarCheckboxTipo(t);
}
}
/// Función para actualizar el estado de las Checkbox de los tipos de multimedia
bool? _actualizarCheckboxTipo(TipoMultimedia t){
if(_formatos[t]!.isEmpty) return false;
bool primero = _seleccionados[_formatos[t]!.first]!;
for(int i = 1; i<_formatos[t]!.length; i++){
if(primero != _seleccionados[_formatos[t]![i]]){
return null;
}
}
return primero;
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: RichText(
maxLines: 1,
overflow: TextOverflow.ellipsis,
text: TextSpan(
children: [
WidgetSpan(
child: widget._carpeta.icono
),
TextSpan(
text: " ${widget._carpeta.nombre}",
style: Theme.of(context).textTheme.titleLarge,
)
]
)
),
),
body: SingleChildScrollView(
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildCheckboxTodos(context),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
RichText(
text: TextSpan(
children: [
const WidgetSpan(
child: Icon(Icons.folder_copy_outlined)
),
const WidgetSpan(
child: SizedBox(width: 15)
),
TextSpan(
text: 'Incluir subcarpetas',
style: Theme.of(context).textTheme.bodyLarge
)
]
)
),
Checkbox(
value: _incluirSubcarpetas,
onChanged: (bool? value) {
setState(() {
_incluirSubcarpetas = value!;
widget._carpeta.pressIncluirSubcarpetas();
widget._lista.actualizaSeleccionable(widget._indice, widget._carpeta);
});
})
],
),
),
Divider(
thickness: 3
),
_buildFilasFormato(context)
],
),
),
);
}
/// Construye las checkbox para los tipos de multimedia
Widget _buildCheckboxTodos(BuildContext context){
const Map<TipoMultimedia, String> textos = {
TipoMultimedia.imagen : 'Todas las imágenes',
TipoMultimedia.audio : 'Todos los audios',
TipoMultimedia.video : 'Todos los vídeos'
};
final lista = <Widget>[];
for(TipoMultimedia t in TipoMultimedia.values){
if(_formatos[t]!.isNotEmpty){
lista.add(Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
RichText(
text: TextSpan(
children: [
WidgetSpan(
child: t.icono
),
const WidgetSpan(
child: SizedBox(width: 15)
),
TextSpan(
text: textos[t]!,
style: Theme.of(context).textTheme.bodyLarge
)
]
)
),
Checkbox(
tristate: true,
value: _allOfType[t],
onChanged: (bool? value) {
setState(() {
_allOfType[t] = value ??
((_allOfType[t] == null)? true : !(_allOfType[t]!));
for(InfoFormato i in widget._formatosCarpeta){
if(_formatos[t]!.contains(i.formatoOriginal)
&& !(!_incluirSubcarpetas && i.carpeta.incluyeSubcarpetas)){
_seleccionados[i.formatoOriginal] = _allOfType[t]!;
widget._carpeta.pressAltSeleccionado(widget._carpeta.formatos.indexOf(i));
}
}
widget._lista.actualizaSeleccionable(widget._indice, widget._carpeta);
});
})
],
));
}
}
return Padding(
padding: EdgeInsets.all(16.0),
child: Column(
children: lista,
));
}
/// Construye las entradas para cada formato
Widget _buildFilasFormato(BuildContext context){
List<Widget> listaCarpeta = <Widget>[];
List<Widget> listaSubcarpeta = <Widget>[];
int index = 0;
for(InfoFormato i in widget._formatosCarpeta){
++index;
Widget fila = Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
RichText(
text: TextSpan(
children: [
WidgetSpan(
child: i.formatoOriginal.tipoMultimedia.icono
),
const WidgetSpan(
child: SizedBox(width: 15)
),
TextSpan(
text: 'Archivos ${i.formatoOriginal.name}',
style: Theme.of(context).textTheme.bodyLarge
)
]
)
),
Wrap(
children: [
Material(
child: Checkbox(
value: _seleccionados[i.formatoOriginal]!,
onChanged: (i.carpeta.incluyeSubcarpetas && !_incluirSubcarpetas)? null :
(bool? value) {
setState(() {
_seleccionados[i.formatoOriginal] = value!;
_allOfType[i.formatoOriginal.tipoMultimedia] =
_actualizarCheckboxTipo(i.formatoOriginal.tipoMultimedia);
widget._carpeta.pressAltSeleccionado(widget._carpeta.formatos.indexOf(i));
widget._lista.actualizaSeleccionable(widget._indice, widget._carpeta);
});
}),
),
IconButton(
icon: const Icon(Icons.edit),
onPressed: ((i.carpeta.incluyeSubcarpetas && !_incluirSubcarpetas)
|| !_seleccionados[i.formatoOriginal]!) ? null :
() {
Navigator.push(context,
MaterialPageRoute(builder: (context) {
return PaginaConfiguracion(
lista: widget._lista,
indice: index-1,
elemento: i,
carpeta: widget._carpeta
);
/*
return PaginaConversion.carpeta(
formatoOriginal: i.formatoOriginal,
indiceArchivo: widget._indice,
carpeta: widget._carpeta,
infoFormato: i,
lista: widget._lista
);
*/
}
));
},
)
]
)
],
);
if(i.carpeta.incluyeSubcarpetas) {
listaSubcarpeta.add(fila);
} else {
listaCarpeta.add(fila);
}
}
return Padding(
padding: EdgeInsets.all(16.0),
child: Column(
children: [
...listaCarpeta,
...listaSubcarpeta
]
));
}
}
import 'package:flutter/material.dart';
import 'package:prueba_multimedia/modelo/modelo.dart';
/// Página para seleccionar el formato de salida de un archivo, enlace o formato
class PaginaConversion extends StatefulWidget {
final ListaSeleccionables _lista;
final int _indiceArchivo;
/// Carpeta a la que pertenece (si es un formato en carpeta)
final Carpeta? _carpeta;
final InfoFormato? _infoFormato;
final Convertible? _archivo;
final Formato _formatoOriginal;
/// Constructor para archivos y enlaces
const PaginaConversion.convertible({
super.key,
required Formato formatoOriginal,
required int indiceArchivo,
required Convertible elemento,
required ListaSeleccionables lista
}): _formatoOriginal = formatoOriginal,
_indiceArchivo = indiceArchivo,
_archivo = elemento,
_carpeta = null,
_infoFormato = null,
_lista = lista;
/// Constructor para formatos en carpetas
const PaginaConversion.carpeta({
super.key,
required Formato formatoOriginal,
required int indiceArchivo,
required Carpeta carpeta,
required InfoFormato infoFormato,
required ListaSeleccionables lista
}): _formatoOriginal = formatoOriginal,
_indiceArchivo = indiceArchivo,
_archivo = null,
_carpeta = carpeta,
_infoFormato = infoFormato,
_lista = lista;
@override
State<PaginaConversion> createState() => _PaginaConversionState();
}
class _PaginaConversionState extends State<PaginaConversion>
with SingleTickerProviderStateMixin {
/// Formato de destino
Formato? _formatoConvertido;
Calidad? _calidadActual;
/// Controla si mostrar o no los perfiles
bool _showProfiles = true;
/// Controla qué tab mostrar en la página para formatos de vídeo
late final TabController _tabController;
/// Inicializa el tab controller
@override
void initState() {
super.initState();
_tabController = TabController(length: 3, vsync: this);
if(widget._carpeta != null){
_formatoConvertido = widget._infoFormato!.formatoDestino;
_calidadActual = widget._infoFormato!.calidadSalida;
}
else{
_formatoConvertido = widget._archivo!.formatoDestino;
_calidadActual = widget._archivo!.calidadSalida;
}
}
/// Elimina el tab controller
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Material(
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Padding(
padding: const EdgeInsets.all(15.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
children: [
const SizedBox(height: 8.0),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_createFormatBox(true),
const SizedBox(width: 10.0),
const Icon(Icons.chevron_right),
const SizedBox(width: 10.0),
_createFormatBox(false)
],
),
],
),
),
_createConversionSelectionPanel()
],
),
);
}
/// Construye la caja con el formato original o el seleccionado
Widget _createFormatBox(bool original){
final String nombreFormato;
final Color fondo;
if(original){
nombreFormato = widget._formatoOriginal.name.toUpperCase();
fondo = Colors.black26;
}
else{
if(_formatoConvertido == null){
nombreFormato = ' ';
fondo = Colors.white;
} else {
nombreFormato = _formatoConvertido!.name.toUpperCase();
fondo = Theme.of(context).colorScheme.inversePrimary;
}
}
return DecoratedBox(
decoration: BoxDecoration(
border: Border.all(
color: Colors.black,
),
borderRadius: BorderRadius.circular(15),
color: fondo
),
child: Padding(
padding: const EdgeInsets.all(13.0),
child: Text(
nombreFormato,
style: Theme.of(context).textTheme.titleLarge,
textScaler: TextScaler.linear(1.3)
),
)
);
}
Widget _createCalidadSelectionPanel() {
final listaChips = Calidad.values.map((elemento) {
return ChoiceChip(
selected: elemento == _calidadActual,
shape: StadiumBorder(),
label: Text(elemento.texto),
onSelected: (_formatoConvertido != null && _formatoConvertido!.listCalidades.contains(elemento))?
(selected) { setState((){_calidadActual = elemento;}); } : null,
);
}).toList();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'Calidad:',
style: Theme.of(context).textTheme.titleSmall
),
const SizedBox(height: 8.0),
Wrap(
spacing: 10.0,
children: listaChips,
)
],
);
}
/// Construye el panel de selección de conversión
Widget _createConversionSelectionPanel(){
if(widget._formatoOriginal.tipoMultimedia == TipoMultimedia.video && !_showProfiles){
return _createTabVideos();
}
else{
return _createDefaultConversionPanel();
}
}
/// Construye las tabs para los vídeos
Widget _createTabVideos(){
final tabs = <Widget>[];
final grids = <Widget>[];
final tipos = TipoMultimedia.values;
for (var element in tipos) {
if(widget._carpeta != null && element == TipoMultimedia.imagen) continue;
tabs.add(Tab(
icon: element.icono,
text: element.nombre,
));
grids.add(GridView.count(
crossAxisCount: 3,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
children: _createGridBotonesConversion(tipo: element),
));
}
return Material(
clipBehavior: Clip.antiAlias,
shape: BeveledRectangleBorder(
side: BorderSide(
color: Colors.black,
width: 1
),
borderRadius: BorderRadius.only(
topLeft: Radius.circular(20.0),
topRight: Radius.circular(20.0)
)
),
child: Container(
height: MediaQuery.of(context).size.height - 380,
width: MediaQuery.of(context).size.width,
color: Theme.of(context).colorScheme.surfaceContainerHighest,
child: Padding(
padding: const EdgeInsets.all(20.0),
child: Column(
children: [
_createBotonesPerfilFormato(),
SizedBox(
height: 68,
child: TabBar(
controller: _tabController,
tabs: tabs
),
),
SizedBox(
height: MediaQuery.of(context).size.height - 528,
child: Padding(
padding: const EdgeInsets.only(top: 16.0),
child: TabBarView(
controller: _tabController,
children: grids
),
),
),
],
),
),
),
);
}
/// Crea el panel de selección de formato o perfil
Widget _createDefaultConversionPanel() {
return Material(
clipBehavior: Clip.antiAlias,
shape: BeveledRectangleBorder(
side: BorderSide(
color: Colors.black,
width: 1
),
borderRadius: BorderRadius.only(
topLeft: Radius.circular(20.0),
topRight: Radius.circular(20.0)
)
),
child: Container(
height: MediaQuery.of(context).size.height - 380,
width: MediaQuery.of(context).size.width,
color: Theme.of(context).colorScheme.surfaceContainerHighest,
child: Padding(
padding: const EdgeInsets.all(20.0),
child: Column(
children: [
_createBotonesPerfilFormato(),
SizedBox(
height: MediaQuery.of(context).size.height - 460,
child: Padding(
padding: const EdgeInsets.only(top: 16.0),
child: GridView.count(
crossAxisCount: _showProfiles? 1 : 3,
childAspectRatio: _showProfiles ? 6.0 : 1.0,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
children: _showProfiles? _createListPerfiles() : _createGridBotonesConversion()
),
),
),
],
),
),
),
);
}
/// Construye los botones que permiten alternar entre perfiles o formatos
Widget _createBotonesPerfilFormato(){
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Material(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20.0),
),
color: (_showProfiles)? Theme.of(context).colorScheme.inversePrimary : Theme.of(context).cardColor,
child: InkWell(
onTap: () {
setState(() {
_showProfiles = true;
});
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 6.0),
child: Text(
'Perfiles',
style: Theme.of(context).textTheme.titleLarge,
),
),
),
),
const SizedBox(width: 16.0),
Material(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20.0),
),
color: (!_showProfiles)? Theme.of(context).colorScheme.inversePrimary : Theme.of(context).cardColor,
child: InkWell(
onTap: () {
setState(() {
_showProfiles = false;
});
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 6.0),
child: Text(
'Formatos',
style: Theme.of(context).textTheme.titleLarge,
),
),
),
)
],
);
}
/// Construye la lista de perfiles
List<Widget> _createListPerfiles(){
final toRet = <Widget>[];
for(Perfil perfil in Perfil.values){
// Averigua de que formato y tipo multimedia es el perfil
Formato? formatoPerfil = perfil.extensionImagen;
if(widget._formatoOriginal.tipoMultimedia == TipoMultimedia.video){
formatoPerfil = perfil.extensionVideo;
}
if(widget._formatoOriginal.tipoMultimedia == TipoMultimedia.audio){
formatoPerfil = perfil.extensionAudio;
}
if(formatoPerfil != null){
Color color = Theme.of(context).cardColor;
if(formatoPerfil == widget._formatoOriginal){
color = Theme.of(context).disabledColor;
}
if(formatoPerfil == _formatoConvertido && perfil.calidad == _calidadActual){
color = Theme.of(context).colorScheme.inversePrimary;
}
toRet.add(Material(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20.0),
),
color: color,
child: InkWell(
onTap: (formatoPerfil == widget._formatoOriginal)? () {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('El archivo ya coincide con este perfil'),
),
);
} : () {
if (_formatoConvertido != formatoPerfil && _calidadActual != perfil.calidad) {
_cambiarFormatoCalidad(formatoPerfil!, perfil.calidad);
}
},
child: Container(
decoration: BoxDecoration(
border: Border.all(
color: Colors.black
),
borderRadius: BorderRadius.circular(20.0),
),
child: Center(
child: Text(
perfil.nombre,
style: Theme.of(context).textTheme.headlineMedium,
),
),
),
),
));
}
}
return toRet;
}
/// Construye la rejilla de botones de conversión
List<Widget> _createGridBotonesConversion({TipoMultimedia? tipo}){
tipo ??= widget._formatoOriginal.tipoMultimedia;
final listaFormatos = Formato.listadoFormatos(
tipo: tipo,
excepcion: widget._formatoOriginal
);
return listaFormatos.map((elemento) {
return Material(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20.0),
),
color: _formatoConvertido == elemento ? Theme.of(context).colorScheme.inversePrimary
: Theme.of(context).cardColor,
child: InkWell(
borderRadius: BorderRadius.circular(20.0),
onTap: _formatoConvertido == elemento ? () {
setState(() {
_formatoConvertido = _calidadActual = null;
_cambiarFormatoCalidad(null, null);
});
} : () {
Calidad? qual = null;
if(elemento.listCalidades.isNotEmpty){
if(elemento.listCalidades.contains(Calidad.media)) {
qual = Calidad.media;
} else {
qual = elemento.listCalidades.first;
}
}
_cambiarFormatoCalidad(elemento, qual);
},
onLongPress: () {
showDialog<void>(
context: context,
builder: (context) {
return AlertDialog(
title: Text(
elemento.nombre,
textAlign: TextAlign.center,
),
content: SizedBox(
child: Wrap(
children: [
Text(
'Clasificación: ${elemento.clasificacion.nombre}',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.bold
),
),
Text('\n'),
Text(
elemento.descripcion,
style: Theme.of(context).textTheme.bodyLarge,
)
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cerrar'),
),
],
);
},
);
},
child: Container(
decoration: BoxDecoration(
border: Border.all(
color: Colors.black
),
borderRadius: BorderRadius.circular(20.0)
),
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
elemento.name.toUpperCase(),
style: Theme.of(context).textTheme.titleLarge,
textScaler: TextScaler.linear(1.5),
),
SizedBox(
height: 15.0,
child: Icon(
elemento.clasificacion.icono,
size: (elemento.clasificacion == Clasificacion.calidad)? 32.0 : 28.0,
),
)
],
)
)
),
),
);
}).toList();
}
/// Actualiza el formato seleccionado
void _cambiarFormatoCalidad(Formato? destino, Calidad? calidad){
setState((){
_formatoConvertido = destino;
_calidadActual = calidad;
ElementoSeleccionable resultado;
if(widget._carpeta != null){
widget._carpeta!.setFormatoDestino(widget._formatoOriginal, _formatoConvertido, _calidadActual);
resultado = widget._carpeta!;
}
else{
widget._archivo!.formatoDestino = _formatoConvertido;
widget._archivo!.calidadSalida = _calidadActual;
if(widget._archivo!.formatoOriginal.tipoMultimedia == TipoMultimedia.video &&
_formatoConvertido?.tipoMultimedia == TipoMultimedia.imagen){
Archivo arch = widget._archivo! as Archivo;
arch.fotograma = 0;
resultado = arch;
}
else{
resultado = widget._archivo!;
}
}
widget._lista.actualizaSeleccionable(widget._indiceArchivo, resultado);
});
}
}
\ No newline at end of file
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:path_provider/path_provider.dart';
import '../modelo/modelo.dart';
/// Página para seleccionar fotogramas dentro de un vídeo que se convierte a
/// imagen
class PaginaFotograma extends StatefulWidget {
final ListaSeleccionables _lista;
final Archivo _archivo;
final int _indice;
const PaginaFotograma({
super.key,
required ListaSeleccionables lista,
required Archivo archivo,
required int indice,
}): _lista = lista, _archivo = archivo, _indice = indice;
@override
State<PaginaFotograma> createState() => _PaginaFotogramaState();
}
class _PaginaFotogramaState extends State<PaginaFotograma> {
int _fotogramaSeleccionado = 0;
int _ultimoFotograma = 0;
double _duracionVideo = 0;
final _buttons = _IconButtons.values;
Timer? timer;
@override
void initState() {
loadNumberFotogramas();
if(widget._archivo.fotograma != null){
_fotogramaSeleccionado = widget._archivo.fotograma!;
}
super.initState();
}
@override
void dispose() {
clearTemp();
super.dispose();
}
@override
Widget build(BuildContext context) {
if(widget._archivo.formatoDestino == null){
return Center(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
'Para seleccionar un fotograma debe elegir un formato de imagen',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleLarge,
),
),
);
}
if(widget._archivo.formatoDestino!.tipoMultimedia != TipoMultimedia.imagen){
return Center(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
'El formato seleccionado no es un formato de imagen',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleLarge,
),
),
);
}
if(_ultimoFotograma == 0){
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
Text(
'Cargando...',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleLarge,
)
],
),
);
}
return Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
SizedBox(
height: (MediaQuery.of(context).size.width - 16.0*2)*9.0/16.0,
child: FutureBuilder(
future: loadFotograma(_fotogramaSeleccionado),
builder: (context, asyncSnapshot) {
if(asyncSnapshot.connectionState == ConnectionState.done){
imageCache.clear();
return Image.file(
asyncSnapshot.data!,
key: UniqueKey(),
);
}
else{
return Transform.scale(
scaleX: 2,
scaleY: 2/5,
child: CircularProgressIndicator()
);
}
}
),
),
const SizedBox(height: 48.0),
Slider(
min: 0.0,
max: _ultimoFotograma.toDouble(),
divisions: _ultimoFotograma,
value: _fotogramaSeleccionado.toDouble(),
label: (_fotogramaSeleccionado+1).toString(),
onChanged: (value) {
setState(() {
_fotogramaSeleccionado = value.toInt();
});
widget._archivo.fotograma = _fotogramaSeleccionado;
widget._lista.actualizaSeleccionable(widget._indice, widget._archivo);
}
),
RichText(
text: TextSpan(
children: [
TextSpan(
text: 'Fotograma seleccionado: ',
style: Theme.of(context).textTheme.titleLarge,
),
TextSpan(
text: (_fotogramaSeleccionado+1).toString(),
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold
),
)
]
),
),
const SizedBox(height: 24.0),
_construirBotonesFotograma(context)
]
)
);
}
/// Construye los botones para moverse entre fotogramas
Widget _construirBotonesFotograma(BuildContext context) {
final buttons = <Widget>[];
for(var button in _buttons){
buttons.add(GestureDetector(
onLongPressStart: (detail) {
setState(() {
timer = Timer.periodic(const Duration(milliseconds: 100), (t) {
setState(() {
_fotogramaSeleccionado += button.variation;
if(_fotogramaSeleccionado < 0) _fotogramaSeleccionado = 0;
if(_fotogramaSeleccionado > _ultimoFotograma) _fotogramaSeleccionado = _ultimoFotograma;
});
widget._archivo.fotograma = _fotogramaSeleccionado;
widget._lista.actualizaSeleccionable(widget._indice, widget._archivo);
});
});
},
onLongPressEnd: (detail) {
if (timer != null) {
timer!.cancel();
}
},
child: Ink(
decoration: ShapeDecoration(
color: Theme.of(context).colorScheme.surfaceTint,
shape: CircleBorder()
),
child: IconButton(
icon: button.icon,
color: Colors.white,
onPressed: () {
setState(() {
_fotogramaSeleccionado += button.variation;
if(_fotogramaSeleccionado < 0) _fotogramaSeleccionado = 0;
if(_fotogramaSeleccionado > _ultimoFotograma) _fotogramaSeleccionado = _ultimoFotograma;
widget._archivo.fotograma = _fotogramaSeleccionado;
widget._lista.actualizaSeleccionable(widget._indice, widget._archivo);
});
},
),
),
),
);
}
return Row(
mainAxisAlignment: MainAxisAlignment.center,
spacing: 15.0,
children: buttons,
);
}
/// Carga el número de fotogramas y la duración del vídeo
Future<void> loadNumberFotogramas() async {
// Obtener número de fotogramas
int? numFotogramas = await Conversor.getNumFotogramas(widget._archivo);
if(numFotogramas != null && numFotogramas > 0){
setState(() {
_ultimoFotograma = numFotogramas-1;
});
}
// Obtener duración de vídeo
double? duracion = await Conversor.getDuracionVideo(widget._archivo);
if(duracion != null && duracion > 0){
setState(() {
_duracionVideo = duracion-1;
});
}
}
/// Carga la imagen del fotograma seleccionado
Future<File> loadFotograma(int fotograma) async {
final directory = await getApplicationSupportDirectory();
String pathSalida = '${directory.absolute.path}${Platform.pathSeparator}fotograma.${widget._archivo.formatoDestino?.extension}';
await Conversor.getFotograma(widget._archivo, fotograma);
return File(pathSalida).create();
}
/// Limpia la carpeta de archivos temporales
Future<void> clearTemp() async {
final directory = await getApplicationSupportDirectory();
final list = directory.listSync();
for(var arch in list){
await arch.delete();
}
}
}
/// Enumerado de los botones para desplazarse entre fotogramas
enum _IconButtons{
DOBLE_ATRAS(Icon(Icons.keyboard_double_arrow_left), -60),
ATRAS(Icon(Icons.chevron_left), -1),
ADELANTE(Icon(Icons.chevron_right), 1),
DOBLE_ADELANTE(Icon(Icons.keyboard_double_arrow_right), 60);
final Icon icon;
final int variation;
const _IconButtons(this.icon, this.variation);
}
import 'package:flutter/material.dart';
import 'package:prueba_multimedia/modelo/modelo.dart';
/// Página para leer y modificar los metadatos de un archivo o enlace
class PaginaMetadatos extends StatefulWidget {
final ListaSeleccionables _lista;
final int _indice;
final Archivo _archivo;
final List<Metadato> _metadatos;
const PaginaMetadatos({super.key, required ListaSeleccionables lista,
required int indice, required Archivo archivo, required List<Metadato> metadatos}):
_lista = lista, _indice = indice, _archivo = archivo, _metadatos = metadatos;
@override
State<PaginaMetadatos> createState() => _PaginaMetadatosState();
}
class _PaginaMetadatosState extends State<PaginaMetadatos> {
final List<TextEditingController> _controladores = [];
final List<String> _valoresElegidos = [];
/// Inicializa los controladores
@override
void initState() {
super.initState();
for (var metadato in widget._metadatos) {
String valorElegido = metadato.valor;
_valoresElegidos.add(valorElegido);
var controller = TextEditingController(text: metadato.valor);
controller.addListener(
() => setState(() {
valorElegido = controller.text;
metadato.valor = controller.text;
widget._lista.actualizaSeleccionable(widget._indice, widget._archivo);
})
);
_controladores.add(controller);
}
}
/// Libera los recursos de los controladores
@override
void dispose() {
for (var controlador in _controladores) {
controlador.dispose();
}
super.dispose();
}
@override
Widget build(BuildContext context) {
if(widget._metadatos.isNotEmpty){
return SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(15.0),
child: Column(
children: _buildTextFields()
),
),
);
}
else {
String mensaje = 'El formato seleccionado '
'(.${widget._archivo.formatoOriginal.extension}) no admite metadatos';
return Padding(
padding: const EdgeInsets.all(15.0),
child: SafeArea(
child: Align(
alignment: Alignment.center,
child: Text(
mensaje,
style: Theme.of(context).textTheme.titleLarge,
textAlign: TextAlign.center,
),
)
),
);
}
}
List<Widget> _buildTextFields(){
var metadatos = widget._metadatos;
final toRet = <Widget>[];
for(int i = 0; i<metadatos.length && i<4; i++){
toRet.add(_buildTextFieldLabel(i));
toRet.add(_buildTextField(i));
if(i < metadatos.length-1) toRet.add(const SizedBox(height: 16.0));
}
if(metadatos.length > 4){
final sublist = <Widget>[];
sublist.add(const SizedBox(height: 16.0));
for(int i = 4; i<metadatos.length; i++){
sublist.add(_buildTextFieldLabel(i));
sublist.add(_buildTextField(i));
if(i < metadatos.length-1) sublist.add(const SizedBox(height: 16.0));
}
toRet.add(ExpansionTile(
collapsedBackgroundColor: Theme.of(context).colorScheme.inversePrimary,
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: Center(child: Text('Mostrar más metadatos')),
children: [Container(
color: Theme.of(context).scaffoldBackgroundColor,
child: Column(
children: sublist,
),
)]
));
}
return toRet;
}
/// Construye la etiqueta con el título de cada metadato
Widget _buildTextFieldLabel(int index){
return Text(
widget._metadatos[index].info.nombreMostrado,
style: Theme.of(context).textTheme.titleMedium
);
}
/// Construye el campo de texto para modificar cada metadato
Widget _buildTextField(int index){
var metadatos = widget._metadatos;
return TextField(
controller: _controladores[index],
decoration: InputDecoration(
hintText: '${metadatos[index].info.nombreMostrado}...',
border: OutlineInputBorder(),
),
keyboardType:
(metadatos[index].info.esNumerico)? const TextInputType.numberWithOptions(
signed: true,
decimal: false
): null,
);
}
}
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:open_file/open_file.dart';
import 'package:provider/provider.dart';
import 'package:prueba_multimedia/modelo/provider_ajustes.dart';
import 'package:prueba_multimedia/widgets/widgets.dart';
import 'package:prueba_multimedia/modelo/modelo.dart';
import 'package:prueba_multimedia/paginas/paginas.dart';
/// Página principal de la aplicación
class PaginaPrincipal extends StatelessWidget { class PaginaPrincipal extends StatelessWidget {
const PaginaPrincipal({super.key}); const PaginaPrincipal({super.key});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return const Placeholder(); // Función para mostrar la Snackbar después de convertir
void mostrarSnackBarConvertir(){
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(
'¡Conversión de archivos completada!'
)
));
};
return Scaffold(
appBar: AppBar(
title: Text(
'ConVertex',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: Colors.white
),
),
backgroundColor: Theme.of(context).primaryColor,
actions: [
Consumer2<ListaSeleccionables, ProviderAjustes>(
builder: (context, lista, ajustes, child) {
return IconButton(
icon: Icon(
Icons.settings_outlined,
color: Colors.white,
),
onPressed: (lista.convirtiendo)? () {} : () {
Navigator.push(context,
MaterialPageRoute(
builder: (context) {
return PaginaAjustes(
provider: ajustes
);
}
)
);
}
);
}
)
]
),
body: _construirCuerpo(context),
floatingActionButton: _construitFAB(context, mostrarSnackBarConvertir),
floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked,
);
}
/// Construye el cuerpo (lleno o vacío) de la página
Widget _construirCuerpo(BuildContext context) {
return Consumer2<ListaSeleccionables, ProviderAjustes>(
builder: (context, lista, ajustes, child) {
if (lista.seleccionables.isNotEmpty) {
return PaginaPrincipalLlena(listaSeleccionables: lista);
} else {
return PaginaPrincipalVacia();
}
}
);
}
/// Construye los elementos en la parte inferior de la página (FAB o progress bar)
Widget _construitFAB(BuildContext context, VoidCallback function){
return Consumer2<ListaSeleccionables, ProviderAjustes>(
builder: (context, lista, ajustes, child) {
if (lista.convirtiendo) {
return Padding (
padding: const EdgeInsets.all(16.0),
child: ConvertexProgressBar(
progress: lista.progress,
textColor: Colors.black,
completedBackground: Colors.purpleAccent.shade100,
)
);
} else {
return Padding (
padding: const EdgeInsets.all(16.0),
child: ConVertexFabBar(
allowConversion: lista.seleccionables.isNotEmpty,
onConvertSuccess: function,
providerAjustes: ajustes,
)
);
}
}
);
} }
} }
import 'package:flutter/material.dart';
import 'package:prueba_multimedia/modelo/modelo.dart';
import 'package:prueba_multimedia/widgets/carpeta_widget.dart';
import 'package:prueba_multimedia/widgets/widgets.dart';
/// Página principal con la lista de elementos seleccionados para la conversión
class PaginaPrincipalLlena extends StatelessWidget {
final ListaSeleccionables listaSeleccionables;
const PaginaPrincipalLlena({super.key, required this.listaSeleccionables});
@override
Widget build(BuildContext context) {
final seleccionables = listaSeleccionables.seleccionables;
return Padding(
padding: const EdgeInsets.all(10.0),
child: ListView.separated(
itemCount: seleccionables.length,
separatorBuilder: (context, index) {
return const SizedBox(height: 8.0);
},
itemBuilder: (context, index) {
return Dismissible(
key: Key(seleccionables[index].id),
direction: listaSeleccionables.convirtiendo?
DismissDirection.none :
DismissDirection.horizontal,
background: Container(
color: Colors.red,
alignment: Alignment.centerLeft,
child: Padding(
padding: const EdgeInsets.all(4.0),
child: const Icon(
Icons.delete,
color: Colors.white,
size: 31.0
),
),
),
secondaryBackground: Container(
color: Colors.red,
alignment: Alignment.centerRight,
child: Padding(
padding: const EdgeInsets.all(4.0),
child: const Icon(
Icons.delete,
color: Colors.white,
size: 31.0
),
),
),
onDismissed: (direction) {
listaSeleccionables.borraSeleccionable(index);
String nombreCarp = seleccionables[index].nombre;
if(nombreCarp.length > 60){
nombreCarp = '${nombreCarp.substring(0, 61)}...';
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('$nombreCarp borrado'),
action: SnackBarAction(
label: 'Deshacer',
onPressed: () {
listaSeleccionables.reinsertar(index, seleccionables[index]);
}
),
)
);
},
child: (seleccionables[index] is Carpeta)
? CarpetaWidget(
indice: index,
carpeta: seleccionables[index] as Carpeta,
lista: listaSeleccionables
)
: ConvertibleWidget(
indice: index,
convertible: seleccionables[index] as Convertible,
lista: listaSeleccionables
)
);
},
),
);
}
}
import 'package:flutter/material.dart';
/// Página principal sin elementos seleccionables
class PaginaPrincipalVacia extends StatelessWidget {
const PaginaPrincipalVacia({super.key});
@override
Widget build(BuildContext context) {
return SafeArea(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
'No hay archivos seleccionados',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16.0),
Text(
'Pulsa el botón + para agregarlos',
style: Theme.of(context).textTheme.bodyLarge,
),
],
),
)
);
}
}
export 'pagina_configuracion.dart';
export 'pagina_configuracion_carpeta.dart';
export 'pagina_conversion.dart';
export 'pagina_fotograma.dart';
export 'pagina_metadatos.dart';
export 'pagina_principal.dart';
export 'pagina_principal_llena.dart';
export 'pagina_principal_vacia.dart';
export 'pagina_ajustes.dart';
\ No newline at end of file
import 'dart:io';
import 'package:archive/archive_io.dart';
import 'package:ffmpeg_kit_flutter_new/return_code.dart';
import 'package:flutter/material.dart';
import 'package:file_picker/file_picker.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:prueba_multimedia/modelo/modelo.dart';
/// Clase que representa los botones de acción de la página principal
class ActionButton extends StatelessWidget {
final ActionButtonTypes tipoBoton;
final ListaSeleccionables manager;
final BuildContext context;
final VoidCallback? onSuccess;
final ProviderAjustes providerAjustes;
final bool disabled;
const ActionButton({
super.key,
required this.tipoBoton,
required this.manager,
required this.context,
this.onSuccess,
required this.disabled,
required this.providerAjustes
});
@override
Widget build(BuildContext context) {
return Directionality(
textDirection: TextDirection.rtl,
child: FilledButton.icon(
onPressed: getCallback(),
label: Text(
tipoBoton.label,
textScaler: const TextScaler.linear(1.2)),
icon: tipoBoton.icon,
),
);
}
// ------------------------ ACCIONES ------------------------ //
/// Añade un archivo para convertir
void archivoAction() async {
FilePickerResult? result = await FilePicker.platform.pickFiles();
if (result == null) {
_mostrarSnackBar(context, 'No se ha seleccionado ningún archivo');
return;
}
File file = File(result.files.first.path!);
final added = manager.addArchivo(file);
if (!added) {
_mostrarSnackBar(context, 'El archivo seleccionado no es de un formato multimedia conocido');
return;
}
if(onSuccess != null && context.mounted){
onSuccess!();
}
}
/// Añade una carpeta para convertir
void carpetaAction() async {
if(await comprobacionPermisoArchivos(context)){
FilePicker.platform.getDirectoryPath().then((path) {
if (path == null) {
_mostrarSnackBar(context, 'No se ha seleccionado ninguna carpeta');
return;
}
bool inc = (!providerAjustes.cargando &&
providerAjustes.incluirSubcarpetas == 0) ? true : false;
var directory = Directory(path);
final added = manager.addCarpeta(directory, inc);
if (!added) {
_mostrarSnackBar(context, 'La carpeta seleccionada no contiene archivos de formatos multimedia conoocidos');
return;
}
if (onSuccess != null && context.mounted) {
onSuccess!();
}
});
}
}
/// Pide al usuario que introduzca un enlace y descarga el archivo
void enlaceAction() async {
String? url = await showDialog(context: context, builder: (context) {
final textEditingController = TextEditingController();
return SimpleDialog(
title: const Text("Introducir enlace"),
children: [
SimpleDialogOption(
child: Column(
children: [
SimpleDialogOption(
child: TextField(
controller: textEditingController,
),
),
const SizedBox(height: 16.0,),
SimpleDialogOption(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
SimpleDialogOption(
onPressed: () {
Navigator.pop(context, textEditingController.text);
},
child: const Text("Aceptar"),
),
SimpleDialogOption(
onPressed: () { Navigator.pop(context); },
child: const Text("Cancelar"),
),
],
),
)
],
),
)
],
);
});
if (url != null) {
bool added = manager.addEnlace(url);
if (!added) {
_mostrarSnackBar(context, 'El archivo del enlace seleccionado no es de un formato multimedia conocido');
return;
}
if (onSuccess != null && context.mounted) {
onSuccess!();
}
}
}
/// Coloca el resultado de la conversión en la carpeta de salida
void copiarAction() async {
if(await _comprobacionesPreviasConversion(context)) {
String? directorioSalida = providerAjustes.carpetaSalida.isNotEmpty
? providerAjustes.carpetaSalida
: await FilePicker.platform.getDirectoryPath();
final resultados = <Future<ReturnCode?>>[];
if(directorioSalida != null){
_actualizadorProgreso();
while(manager.seleccionables.isNotEmpty){
ElementoSeleccionable sel = manager.seleccionables.first;
if(sel is Carpeta){
Directory d = await Directory("$directorioSalida/${sel.nombre}").create();
for (var archivo in sel.elementosSeleccionados) {
resultados.add(archivo.convertir(d.path));
}
}
else if (sel is Convertible){
resultados.add(sel.convertir(directorioSalida));
}
if (manager.seleccionables.length == 1) {
for (final result in resultados) {
await result;
}
}
manager.borraSeleccionable(0);
}
}
}
}
/// Igual que copiar pero guarda el resultado en un .zip
void comprimirAction() async {
if(await _comprobacionesPreviasConversion(context)) {
// Averiguamos donde colocar los archivos de salida
String? directorioSalida = providerAjustes.carpetaSalida.isNotEmpty
? providerAjustes.carpetaSalida
: await FilePicker.platform.getDirectoryPath();
if(directorioSalida != null) {
_actualizadorProgreso();
final now = DateTime.now();
final nombreZip = "ConVertex_"
"${now.day}-${now.month}-${now.year}_"
"${now.hour}-${now.minute}-${now.second}";
final zipFileEncoder = ZipFileEncoder();
zipFileEncoder.create("$directorioSalida/$nombreZip.zip");
final resultsZip = <Future<void>>[];
while(manager.seleccionables.isNotEmpty){
ElementoSeleccionable sel = manager.seleccionables.first;
// Conversion de carpetas
if(sel is Carpeta){
Directory d = await Directory("$directorioSalida/${sel.nombre}").create();
final resultsConversion = <Future<ReturnCode?>>[];
for (var archivo in sel.elementosSeleccionados) {
resultsConversion.add(archivo.convertir(d.path));
}
// Esperamos a la conversión y añadimos a zip
for (final result in resultsConversion) {
await result;
}
resultsZip.add(
// Cuando se añada al zip eliminamos los archivos temporales
zipFileEncoder.addDirectory(d)..then((_) => d.delete(recursive: true))
);
}
// Conversion de archivos y enlaces
else if (sel is Convertible){
sel.convertir(directorioSalida);
File tempFile = await File(
"$directorioSalida/${sel.nombre}.${sel.formatoDestino?.name}"
).create();
resultsZip.add(
zipFileEncoder.addFile(tempFile)..then((_) => tempFile.delete())
);
}
// Esperamos a que la compresión se termine y se borren los
// archivos temporales
if (manager.seleccionables.length == 1) {
for (final result in resultsZip) {
await result;
}
zipFileEncoder.closeSync();
}
// Esto hace que la barra de progreso suba
manager.borraSeleccionable(0);
}
}
}
}
// No se si podremos implementarlo...
void reemplazarAction() async {}
// ------------------------ UTILIDADES ------------------------ //
/// Actualiza la barra de progreso de la conversión de forma asíncrona
void _actualizadorProgreso() async {
manager.iniciarConversion();
int initialSize = manager.seleccionables.length;
await Future.delayed(const Duration(milliseconds: 200));
while(manager.seleccionables.isNotEmpty){
manager.actualizarProgreso(initialSize);
await Future.delayed(const Duration(milliseconds: 200));
}
if(manager.progress < 100){
manager.actualizarProgreso(initialSize);
await Future.delayed(const Duration(milliseconds: 200));
}
manager.finalizarConversion();
if(onSuccess != null){
onSuccess!();
}
}
/// Comprueba si tenemos permisos suficientes
Future<bool> _comprobacionesPreviasConversion(BuildContext context) async {
if(!await comprobacionPermisoArchivos(context)){
return false;
}
return true;
}
/// Devuelve true si se han concedido los permisos. Si no se han concedido
/// se solicitan, y si se deniegan se devuelve false
static Future<bool> comprobacionPermisoArchivos(BuildContext context) async {
final granted = await Permission.manageExternalStorage.isGranted;
if(!granted) {
final permanently = await Permission.manageExternalStorage.isPermanentlyDenied;
if(permanently) {
_mostrarNotificacion(context,
'El acceso al almacenamiento del dispositivo ha sido denegado permanentemente.\n'
'¿Desea abrir la configuración para modificar el permiso?',
true);
return false;
}
else {
await _mostrarNotificacion(context,
'ConVertex no tiene permiso para acceder a sus archivos. '
'Por favor, para usar esta función, active el permiso a continuación.',
false);
if(await Permission.manageExternalStorage.request().isDenied){
_mostrarNotificacion(context,
'Se ha denegado el permiso de acceso al almacenamiento. '
'Sin dicho permiso, ConVertex no puede acceder a los archivos dentro de sus carpetas.\n'
'Por favor, ceda el permiso para usar esta función.',
false);
return false;
}
}
}
return true;
}
/// Muestra una notificación solo si el contexto está acoplado
static Future<void> _mostrarNotificacion(BuildContext context, String text, bool opcion) async {
if (context.mounted) {
await showDialog(
context: context,
builder: (context) {
return AlertDialog(
content: Text(text),
actions: [
if(opcion) TextButton(
onPressed: () {
Navigator.of(context).pop();
openAppSettings();
},
child: const Text('SÍ'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: (opcion)? const Text('NO') : const Text('OK'),
)
]
);
}
);
}
}
/// Muestra el mensaje en la SnackBar solo si el contexto está acoplado
void _mostrarSnackBar(BuildContext context, String text) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(text))
);
}
}
/// Asigna la función correspondiente según el tipo de botón
void Function()? getCallback() {
if(disabled) return null;
return switch(tipoBoton) {
ActionButtonTypes.archivo => archivoAction,
ActionButtonTypes.carpeta => carpetaAction,
ActionButtonTypes.enlace => enlaceAction,
ActionButtonTypes.copiar => copiarAction,
ActionButtonTypes.comprimir => comprimirAction,
ActionButtonTypes.reemplazar => reemplazarAction,
};
}
}
enum ActionButtonTypes {
archivo('Archivo', Icon(Icons.description_outlined), false),
carpeta('Carpeta', Icon(Icons.folder_outlined), false),
enlace('Enlace', Icon(Icons.link_outlined), false),
copiar('Copiar', Icon(Icons.copy), true),
comprimir('Comprimir', Icon(Icons.splitscreen_outlined), true),
reemplazar('Reemplazar', Icon(Icons.change_circle_outlined), true);
final String label;
final Icon icon;
final bool isConvertir;
const ActionButtonTypes(this.label, this.icon, this.isConvertir);
}
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:prueba_multimedia/paginas/paginas.dart';
import 'package:prueba_multimedia/modelo/modelo.dart';
import 'dart:math';
import 'package:uuid/uuid.dart';
import 'package:uuid/v1.dart';
/// Widget para mostrar carpetas en la lista de archivos de la página principal
class CarpetaWidget extends StatefulWidget {
/// Índice de la carpeta en cuestión en la lista de seleccionables
final int indice;
final Carpeta carpeta;
final ListaSeleccionables lista;
const CarpetaWidget({super.key, required this.indice,
required this.carpeta, required this.lista});
@override
State<CarpetaWidget> createState() => _CarpetaWidgetState();
}
class _CarpetaWidgetState extends State<CarpetaWidget>
with SingleTickerProviderStateMixin
{
static const double maxHeight = 50;
/// Controlador para la animación de apertura y cierre
late final AnimationController _controller;
/// Archivos (o carpetas si es recursivo) que el usuario no ha eliminado
/// de la lista y que coinciden con los formatos elegidos
List<InfoFormato> seleccionados = [];
/// Controla si el widget está abierto o cerrado
bool open = false;
/// Controla la rotación del icono de desplegar / colapsar
double rotAngle = 0;
/// Controla la altura de las entradas de formato
double height = 0;
/// Inicializa el controlador
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 200),
vsync: this
);
_controller.addListener(() {
setState(() {
rotAngle = pi*_controller.value;
height = maxHeight*_controller.value;
});
});
}
/// Libera los recursos del controlador
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
seleccionados = [];
bool incluimosSubcarpetas = widget.carpeta.incluyeSubcarpetas;
for(var i in widget.carpeta.formatos) {
if(i.seleccionado && (!i.subCarpeta || (i.subCarpeta && incluimosSubcarpetas))){
seleccionados.add(i);
}
}
if(seleccionados.isEmpty){
_controller.reset();
}
return SizedBox(
height: (height <= 0)? 60 : 60 + (seleccionados.length*(height+10)),
width: MediaQuery.of(context).size.width,
child: Stack(
children: [
Positioned(
top: 0,
child: buildMainContainer(),
),
...buildSecondaryContainers()
]
),
);
}
/// Construye la entrada de la carpeta
Widget buildMainContainer(){
Carpeta carpeta = widget.carpeta;
// Para el plural indicando los formato(s) seleccionado(s)
String s = (seleccionados.length > 1) ? "s" : "";
return SizedBox(
height: 60,
width: MediaQuery.of(context).size.width-10,
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
Flexible(flex:1, child: carpeta.icono),
const Flexible(flex: 1, child: SizedBox(width: 20)),
Flexible(
flex: 15,
child: SizedBox.expand(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Flexible(
flex: 1,
child: Text(
carpeta.nombre,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.bold
),
overflow: TextOverflow.ellipsis,
),
),
Flexible(
flex: 1,
child: Text(
(seleccionados.isNotEmpty)
? '${seleccionados.length} formato$s seleccionado$s'
: 'Ningún formato seleccionado',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: (seleccionados.isNotEmpty)? null : Colors.red
)
),
),
],
)
),
),
Flexible(
flex: 3,
fit: FlexFit.loose,
child: Transform.rotate(
angle: rotAngle,
child: IconButton(
icon: Icon(
Icons.arrow_drop_down,
color: (seleccionados.isEmpty)
? Theme.of(context).disabledColor
: null,
),
onPressed: (seleccionados.isEmpty)
? null
: () {
setState(() { open = !open; });
_controller.toggle();
}
),
),
),
Flexible(
flex: 3,
child: IconButton(
icon: const Icon(Icons.edit),
onPressed: (widget.lista.convirtiendo)? () {} :
() {
Navigator.push(
context,
MaterialPageRoute(builder: (context) {
return PaginaConfiguracionCarpeta(
indice: widget.indice,
carpeta: carpeta,
lista: widget.lista
);
})
);
},
),
)
]
),
);
}
/// Construye las entradas de los formatos
List<Widget> buildSecondaryContainers(){
Carpeta carpeta = widget.carpeta;
int index = 0;
return seleccionados.map((elemento) {
index++;
String? formatoOrigenYDestino;
formatoOrigenYDestino = elemento.formatoOriginal.name.toUpperCase();
if(elemento.formatoDestino != null) {
formatoOrigenYDestino += " > ${elemento.formatoDestino!.name.toUpperCase()}";
}
return Positioned(
top: (height+10)*index,
child: ClipRect(
clipper: _MyRectClipper(_controller),
child: Dismissible(
key: Key(elemento.id),
direction: widget.lista.convirtiendo?
DismissDirection.none :
DismissDirection.horizontal,
background: Container(
color: Colors.red,
alignment: Alignment.centerLeft,
child: const Padding(
padding: EdgeInsets.only(left: 4.0),
child: Icon(
Icons.check_box_outline_blank,
color: Colors.white,
size: 31.0
),
),
),
secondaryBackground: Container(
color: Colors.red,
alignment: Alignment.centerRight,
child: const Padding(
padding: EdgeInsets.only(right: 14.0),
child: Icon(
Icons.check_box_outline_blank,
color: Colors.white,
size: 31.0
),
),
),
onDismissed: (direction) {
seleccionados.remove(elemento);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'${elemento.nombre} '
'excluido de ${carpeta.nombre}'
),
action: SnackBarAction(
label: 'Deshacer',
onPressed: () { seleccionados.insert(index-1, elemento); }
),
)
);
},
// Esto es en esencia un convertible widget pero que es un poco más
// pequeño
child: SizedBox(
height: 50,
width: MediaQuery.of(context).size.width-10,
child: Padding(
padding: const EdgeInsets.only(left: 24.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
const Flexible(flex: 1, child: Icon(Icons.find_in_page_outlined)),
const Flexible(flex: 1, child: SizedBox(width: 10)),
Flexible(
flex: 13,
child: SizedBox.expand(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Flexible(
flex: 1,
child: Text(
carpeta.nombre,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.bold
),
overflow: TextOverflow.ellipsis
),
),
Flexible(
flex: 1,
child: Text(
formatoOrigenYDestino ?? "Soy una carpeta!",
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.bold
)
),
),
],
)
),
),
Flexible(
flex: 2,
child: IconButton(
icon: const Icon(Icons.edit),
onPressed: (widget.lista.convirtiendo)? () {} :
() {
Navigator.push(context,
MaterialPageRoute(builder: (context) {
return PaginaConfiguracion(
lista: widget.lista,
indice: index-1,
elemento: elemento,
carpeta: carpeta
);
}
));
},
),
)
]
),
),
),
),
),
);
}).toList();
}
}
/// Clase encargada de cortar las entradas de los formatos durante la animación
class _MyRectClipper extends CustomClipper<Rect> {
final AnimationController _controller;
_MyRectClipper(this._controller);
@override
Rect getClip(Size size) {
double value = (1-_controller.value)*_CarpetaWidgetState.maxHeight;
return Rect.fromLTWH(0, value, 1000, _CarpetaWidgetState.maxHeight);
}
@override
bool shouldReclip(covariant CustomClipper<Rect> oldClipper) {
return !(_controller.isDismissed || _controller.isCompleted);
}
}
\ No newline at end of file
import 'dart:io';
import 'package:archive/archive_io.dart';
import 'package:ffmpeg_kit_flutter_new/return_code.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:prueba_multimedia/modelo/modelo.dart';
import 'package:prueba_multimedia/modelo/provider_ajustes.dart';
import 'package:prueba_multimedia/paginas/pagina_configuracion.dart';
import 'package:prueba_multimedia/paginas/pagina_configuracion_carpeta.dart';
import 'package:prueba_multimedia/widgets/widgets.dart';
/// Widget que representa la barra inferior de botones de acción de la pantalla
/// principal
class ConVertexFabBar extends StatefulWidget {
/// Si se permite o no convertir (o sea, si hay o no seleccionables en la lista)
final bool allowConversion;
/// Acción que deben realizar los botones de conversión al finalizar
final VoidCallback onConvertSuccess;
/// Provider con los ajustes de la aplicación
final ProviderAjustes providerAjustes;
const ConVertexFabBar({
super.key,
required this.allowConversion,
required this.onConvertSuccess,
required this.providerAjustes
});
@override
State<ConVertexFabBar> createState() => _ConVertexFabBarState();
}
class _ConVertexFabBarState extends State<ConVertexFabBar> {
/// Si el botón de Agregar está expandido
bool _agregarOpen = false;
/// Si el botón de Convertir está expandido
bool _convertirOpen = false;
/// Clave que se utiliza para abrir y cerrar el botón de Agregar
final _agregarKey = GlobalKey<ExpandableFabState>();
/// Clave que se utiliza para abrir y cerrar el botón de Convertir
final _convertirKey = GlobalKey<ExpandableFabState>();
/// Los botones de acción dentro del botón expansible Agregar
static const agregarButtonTypes = <ActionButtonTypes>[
ActionButtonTypes.archivo,
ActionButtonTypes.carpeta,
ActionButtonTypes.enlace
];
/// Los botones de acción dentro del botón expansible Convertir
static const convertirButtonTypes= <ActionButtonTypes>[
ActionButtonTypes.copiar,
ActionButtonTypes.comprimir
];
@override
Widget build(BuildContext context) {
final manager = Provider.of<ListaSeleccionables>(context, listen: false);
return SizedBox(
width: MediaQuery.of(context).size.width,
height: (_agregarOpen || _convertirOpen)? MediaQuery.of(context).size.height : 56,
child: Stack(
children: [
GestureDetector(
onTap: () {
setState(() {
_agregarOpen = false;
_convertirOpen = false;
});
_agregarKey.currentState?.close();
if(widget.providerAjustes.expandConvert){
_convertirKey.currentState?.close();
}
},
),
Align(
alignment: Alignment.bottomCenter,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.end,
children: [..._buildFABs(manager)],
),
)
],
),
);
}
/// Construye los FABs expansibles
List<Widget> _buildFABs(ListaSeleccionables manager) {
final toRet = <Widget>[];
// FAB con las opciones para convertir
if(widget.providerAjustes.expandConvert) {
toRet.add(Stack(
alignment: Alignment.bottomLeft,
children: [
ExpandableFab(
invert: true,
distance: 60,
icon: Icon(Icons.label_important_outline),
label: 'Convertir',
key: _convertirKey,
children: _loadConvertirActionButtons(context, manager)
),
SizedBox(
width: 130,
height: 56,
child: GestureDetector(
onTap: () {
setState(() {
_agregarOpen = false;
_convertirOpen = !_convertirOpen;
});
_agregarKey.currentState?.close();
if(widget.providerAjustes.expandConvert){
_convertirKey.currentState?.tap();
}
},
),
)
],
));
}
else {
toRet.add(FloatingActionButton.extended(
heroTag: 'btn1',
icon: Icon(Icons.label_important_outline),
label: Text(
'Convertir',
textScaler: TextScaler.linear(1.2)
),
foregroundColor: (!widget.allowConversion)? Colors.grey.shade800 : null,
backgroundColor: (!widget.allowConversion)? Colors.grey.shade200 : null,
disabledElevation: 0,
onPressed: (!widget.allowConversion)? null :
() {
switch(widget.providerAjustes.modoConversion){
case 1:
convertirCopiar(manager);
break;
case 2:
convertirComprimir(manager);
break;
}
},
));
}
// FAB con las opciones para agregar archivos, carpetas, enlaces...
toRet.add(Stack(
alignment: Alignment.bottomRight,
children: [
ExpandableFab(
distance: 60,
icon: Icon(Icons.add),
key: _agregarKey,
children: _loadAgregarActionButtons(context, manager),
),
SizedBox(
width: 56,
height: 56,
child: GestureDetector(
onTap: () {
setState(() {
_convertirOpen = false;
_agregarOpen = !_agregarOpen;
});
if(widget.providerAjustes.expandConvert){
_convertirKey.currentState?.close();
}
_agregarKey.currentState?.tap();
},
),
)
],
));
return toRet;
}
/// Construye los botones de acción dentro del botón expansible Convertir
List<Widget> _loadConvertirActionButtons (BuildContext context,
ListaSeleccionables manager)
{
return convertirButtonTypes.map((type) {
return ActionButton(
tipoBoton: type,
manager: manager,
context: context,
disabled: !(widget.allowConversion) && type.isConvertir,
onSuccess: widget.onConvertSuccess,
providerAjustes: widget.providerAjustes,
);
}).toList();
}
/// Construye los botones de acción dentro del botón expansible Agregar
List<Widget> _loadAgregarActionButtons(BuildContext context,
ListaSeleccionables manager)
{
accionesFinal() {
setState(() {
_convertirOpen = false;
_agregarOpen = false;
});
if(widget.providerAjustes.expandConvert){
_convertirKey.currentState?.close();
}
_agregarKey.currentState?.close();
final manager2 = Provider.of<ListaSeleccionables>(context, listen: false);
int ix = manager2.seleccionables.length-1;
Navigator.push(context,
MaterialPageRoute(
builder: (context) {
if(manager2.seleccionables[ix] is Carpeta){
return PaginaConfiguracionCarpeta(
lista: manager2,
indice: ix,
carpeta: manager2.seleccionables[ix] as Carpeta
);
}
return PaginaConfiguracion(
lista: manager2,
indice: ix,
elemento: manager2.seleccionables[ix] as Convertible
);
}
)
);
}
return agregarButtonTypes.map( (type) {
return ActionButton(
tipoBoton: type,
manager: manager,
context: context,
onSuccess: accionesFinal,
disabled: false,
providerAjustes: widget.providerAjustes,
);
}).toList();
}
/// Acción que realiza el botón de convertir cuando el modo Copiar está
/// seleccionado en Ajustes
Future<void> convertirCopiar(ListaSeleccionables manager) async {
if(await ActionButton.comprobacionPermisoArchivos(context)){
String? directorioSalida;
if(widget.providerAjustes.carpetaSalida.isNotEmpty){
directorioSalida = widget.providerAjustes.carpetaSalida;
}
else{
directorioSalida = await FilePicker.platform.getDirectoryPath();
}
if(directorioSalida != null){
listenerActualizar(manager);
// Convertimos los archivos como tal
List<Archivo> archivos = manager.seleccionables.whereType<Archivo>().toList();
while(manager.seleccionables.isNotEmpty){
ElementoSeleccionable sel = manager.seleccionables.first;
if(sel is Carpeta){
for (var archivo in sel.elementosSeleccionados) {
Directory d = await Directory("$directorioSalida/${sel.nombre}").create();
Conversor.convertir(archivo, d.path);
}
}
else if (sel is Archivo){
Conversor.convertir(sel, directorioSalida);
}
manager.borraSeleccionable(0);
}
}
}
}
/// Acción que realiza el botón de convertir cuando el modo Comprimir está
/// seleccionado en Ajustes
Future<void> convertirComprimir(ListaSeleccionables manager) async {
if(await ActionButton.comprobacionPermisoArchivos(context)){
// Averiguamos donde colocar los archivos de salida
String? directorioSalida = widget.providerAjustes.carpetaSalida.isNotEmpty
? widget.providerAjustes.carpetaSalida
: await FilePicker.platform.getDirectoryPath();
if(directorioSalida != null) {
listenerActualizar(manager);
final now = DateTime.now();
final nombreZip = "ConVertex_"
"${now.day}-${now.month}-${now.year}_"
"${now.hour}-${now.minute}-${now.second}";
final zipFileEncoder = ZipFileEncoder();
zipFileEncoder.create("$directorioSalida/$nombreZip.zip");
final resultsZip = <Future<void>>[];
while(manager.seleccionables.isNotEmpty){
ElementoSeleccionable sel = manager.seleccionables.first;
// Conversion de carpetas
if(sel is Carpeta){
Directory d = await Directory("$directorioSalida/${sel.nombre}").create();
final resultsConversion = <Future<ReturnCode?>>[];
for (var archivo in sel.elementosSeleccionados) {
resultsConversion.add(Conversor.convertir(archivo, d.path));
}
// Esperamos a la conversión y añadimos a zip
for (final result in resultsConversion) {
await result;
}
resultsZip.add(
zipFileEncoder.addDirectory(d)..then((_) => d.delete(recursive: true))
);
}
// Conversion de archivos
else if (sel is Archivo){
// Esperamos a la conversión y añadimos a zip
await Conversor.convertir(sel, directorioSalida);
File tempFile = await File(
"$directorioSalida/${sel.nombre}.${sel.formatoDestino?.name}"
).create();
resultsZip.add(
zipFileEncoder.addFile(tempFile)..then((_) => tempFile.delete())
);
}
// Esperamos a que la compresión se termine y se borren los
// archivos temporales
if (manager.seleccionables.length == 1) {
for (final result in resultsZip) {
await result;
}
zipFileEncoder.closeSync();
}
// Esto hace que la barra de progreso suba
manager.borraSeleccionable(0);
}
}
}
}
/// Listener que se encarga de actualizar el progreso conforme se convierten
/// los archivos
Future<void> listenerActualizar(ListaSeleccionables manager) async {
manager.iniciarConversion();
int initialSize = manager.seleccionables.length;
await Future.delayed(Duration(milliseconds: 200));
while(manager.seleccionables.isNotEmpty){
manager.actualizarProgreso(initialSize);
await Future.delayed(Duration(milliseconds: 200));
}
if(manager.progress < 100){
manager.actualizarProgreso(initialSize);
await Future.delayed(Duration(milliseconds: 200));
}
manager.finalizarConversion();
widget.onConvertSuccess();
}
}
import 'package:flutter/material.dart';
/// Un pequeño Widget que contiene la barra de progreso de la conversión
class ConvertexProgressBar extends StatelessWidget {
/// Progreso de la conversión
final int progress;
/// Alto y ancho de la barra
final double? width;
final double? height;
/// Colores de fondo, borde y texto de la barra de progreso
final Color? background;
final Color? completedBackground;
final Color? borderColor;
final Color? textColor;
const ConvertexProgressBar({
super.key,
required this.progress,
this.width,
this.height,
this.background,
this.completedBackground,
this.borderColor,
this.textColor
});
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
double defHeight = (constraints.maxHeight > 64)? 64.0 : constraints.maxHeight;
double? both;
Color completed = completedBackground ?? Colors.red;
if(progress == 0){
both = 0;
}
else if(progress == 100){
both = 100;
}
return SizedBox(
width: width ?? constraints.maxWidth,
height: height ?? defHeight,
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
completed,
completed,
background ?? completed.withAlpha(0),
background ?? completed.withAlpha(0)
],
stops: [
0,
both ?? ((progress > 5)? (progress-5)/100 : 0.0),
both ?? ((progress < 95)? (progress+5)/100 : 1.0),
1.0
]
),
border: Border.fromBorderSide(
BorderSide(
color: borderColor ?? Colors.black,
width: 3.0
)
),
borderRadius: BorderRadius.circular(22.0)
),
child: FittedBox(
child: Text(
'$progress%',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: textColor ?? Colors.white,
fontWeight: FontWeight.bold
),
),
),
),
);
}
);
}
}
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:prueba_multimedia/modelo/provider_ajustes.dart';
import 'package:prueba_multimedia/paginas/paginas.dart';
import 'package:prueba_multimedia/modelo/modelo.dart';
import 'paginas/pagina_principal.dart'; /// Clase que representa la aplicación de ConVertex
class ConvertexPrototipoApp extends StatelessWidget { class ConvertexPrototipoApp extends StatelessWidget {
const ConvertexPrototipoApp({super.key}); const ConvertexPrototipoApp({super.key});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MaterialApp( return MaterialApp(
title: 'ConVertex',
theme: ThemeData( theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple) colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple)
), ),
...@@ -18,7 +22,13 @@ class ConvertexPrototipoApp extends StatelessWidget { ...@@ -18,7 +22,13 @@ class ConvertexPrototipoApp extends StatelessWidget {
) )
), ),
themeMode: ThemeMode.system, themeMode: ThemeMode.system,
home: PaginaPrincipal(), home: MultiProvider(
providers: [
ChangeNotifierProvider(create: (context) => ListaSeleccionables()),
ChangeNotifierProvider(create: (context) => ProviderAjustes())
],
child: PaginaPrincipal(),
),
); );
} }
} }
import 'package:flutter/material.dart';
import 'package:prueba_multimedia/paginas/paginas.dart';
import 'package:prueba_multimedia/modelo/modelo.dart';
/// Widget para mostrar archivos y enlaces en la lista de archivos de la página principal
class ConvertibleWidget extends StatelessWidget {
/// Índice del archivo o enlace en cuestión en la lista de seleccionables
final int indice;
final Convertible convertible;
final ListaSeleccionables lista;
const ConvertibleWidget({super.key,
required this.indice, required this.convertible, required this.lista});
@override
Widget build(BuildContext context) {
final String nombre = convertible.nombre;
int index = nombre.lastIndexOf('.');
final String nombreConvertible = index > -1
? nombre.substring(0, index)
: nombre;
// Descripción debajo del nombre del convertible con los formatos
String formatoOrigenYDestino = convertible.formatoOriginal.name.toUpperCase();
if(convertible.formatoDestino != null) {
formatoOrigenYDestino +=
" > ${convertible.formatoDestino!.name.toUpperCase()}";
}
return SizedBox(
height: 60,
width: MediaQuery.of(context).size.width,
child: Row(
children: <Widget>[
Flexible(flex: 1, child: convertible.icono),
const Flexible(flex: 1, child: SizedBox(width: 15)),
Flexible(
flex: 16,
child: SizedBox.expand(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Flexible(
flex: 1,
child: Text(
nombreConvertible,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.bold
)
),
),
Flexible(
flex: 1,
child: Text(formatoOrigenYDestino)
)
],
)
),
),
Flexible(
flex: 2,
child: IconButton(
icon: const Icon(Icons.edit),
onPressed: (lista.convirtiendo)? () {} :
() {
Navigator.push(context,
MaterialPageRoute(builder: (context) {
return PaginaConfiguracion(
indice: indice,
elemento: convertible,
lista: lista
);
}));
},
),
)
]
)
);
}
}
import 'dart:math';
import 'package:flutter/material.dart';
/// Widget que contiene un botón expansible, como los usados en la página
/// principal. Inspirados en [https://docs.flutter.dev/cookbook/effects/expandable-fab]
@immutable
class ExpandableFab extends StatefulWidget {
final bool? initialOpen;
/// Si es true, se alinea a la izquierda
final bool invert;
/// Icono del FAB
final Icon icon;
/// Texto del FAB
final String? label;
/// Distancia a la que se crean los hijos del FAB
final double distance;
/// Lista de hijos del FAB
final List<Widget> children;
const ExpandableFab({
super.key,
this.initialOpen,
this.invert = false,
required this.icon,
this.label,
required this.distance,
required this.children
});
@override
State<ExpandableFab> createState() => ExpandableFabState();
}
class ExpandableFabState extends State<ExpandableFab>
with SingleTickerProviderStateMixin {
/// Controladores de la animación
late final AnimationController _controller;
late final Animation<double> _expandAnimation;
/// Si está abierto
bool _open = false;
@override
void initState() {
super.initState();
_open = widget.initialOpen ?? false;
_controller = AnimationController(
value: _open? 1.0 : 0.0,
duration: const Duration(milliseconds: 250),
vsync: this
);
_expandAnimation = CurvedAnimation(
curve: Curves.fastOutSlowIn,
reverseCurve: Curves.easeOutQuad,
parent: _controller
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
/// Simula una pulsación sobre el botón
void tap(){
_toggle();
}
/// Simula el cierre del botón
void close(){
if(_open){
_toggle();
}
}
/// Alterna el estado abierto-cerrado
void _toggle(){
setState(() {
_open = !_open;
if(_open){
_controller.forward();
}
else{
_controller.reverse();
}
});
}
@override
Widget build(BuildContext context) {
return SizedBox(
width: _computeWidth(),
height: _open? 56 + (widget.distance*widget.children.length) : 56,
child: SizedBox.expand(
child: Stack(
alignment: widget.invert? Alignment.bottomLeft : Alignment.bottomRight,
clipBehavior: Clip.none,
children: [
GestureDetector(
onTap: () {if(_open) _toggle();},
),
_buildTapToCloseFab(),
..._buildExpandingActionButtons(),
_buildTapToOpenFab()]
),
),
);
}
/// Construye la X que contrae el FAB
Widget _buildTapToCloseFab() {
return SizedBox(
width: 56,
height: 56,
child: Center(
child: Material(
shape: const RoundedRectangleBorder(),
clipBehavior: Clip.antiAlias,
elevation: 4,
child: InkWell(
onTap: _toggle,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Icon(Icons.close,
color: Theme.of(context).primaryColor
),
),
),
),
),
);
}
/// Construye el contenedor que expande las opciones
Widget _buildTapToOpenFab() {
return IgnorePointer(
ignoring: _open,
child: AnimatedContainer(
transformAlignment: Alignment.center,
transform: Matrix4.diagonal3Values(
_open? 0.7 : 1.0,
_open? 0.7 : 1.0,
1.0
),
duration: const Duration(milliseconds: 250),
curve: const Interval(0.0, 0.5, curve: Curves.easeOut),
child: AnimatedOpacity(
opacity: _open? 0.0 : 1.0,
curve: const Interval(0.0, 0.5, curve: Curves.easeInOut),
duration: const Duration(milliseconds: 250),
child: _loadFloatingActionButton()
),
),
);
}
/// Construye el botón del FAB que expande las opciones
Widget _loadFloatingActionButton(){
if(widget.label == null){
return FloatingActionButton(
onPressed: _toggle,
child: widget.icon,
);
}
return FloatingActionButton.extended(
heroTag: (widget.invert)? 'btn1' : 'btn2',
onPressed: _toggle,
icon: widget.icon,
label: Text(
widget.label!,
textScaler: TextScaler.linear(1.2)
)
);
}
/// Construye las opciones del FAB
List<Widget> _buildExpandingActionButtons() {
final children = <Widget>[];
final count = widget.children.length;
final step = 90.0 / (count - 1);
for(var i = 0; i < count; i++){
children.add(
_ExpandingActionButton(
invert: widget.invert,
directionInDegrees: 90,
maxDistance: widget.distance * (i+1),
progress: _expandAnimation,
child: widget.children[i]
)
);
}
return children;
}
/// Obtiene la anchura que debería tener el botón
double _computeWidth() {
if(_open){
if(widget.label != null) return 170.0;
return 130.0;
}
else{
if(widget.label != null) return 130.0;
return 56.0;
}
}
}
/// Clase que se utiliza para construir la animación del FAB abriéndose y cerrándose
class _ExpandingActionButton extends StatelessWidget {
final bool invert;
final double directionInDegrees;
final double maxDistance;
final Animation<double> progress;
final Widget child;
const _ExpandingActionButton({
this.invert = false,
required this.directionInDegrees,
required this.maxDistance,
required this.progress,
required this.child
});
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: progress,
builder: (context, child) {
final offset = Offset.fromDirection(
directionInDegrees * (pi / 180.0),
progress.value * maxDistance
);
return Positioned(
right: invert? null : 4.0 + offset.dx,
bottom: 4.0 + offset.dy,
//top: 0,
left: invert? 4.0 + offset.dx : null,
child: child!
);
},
child: FadeTransition(opacity: progress, child: child)
);
}
}
export 'action_button.dart';
export 'carpeta_widget.dart';
export 'convertex_fab_bar.dart';
export 'convertex_progress_bar.dart';
export 'convertex_prototipo_app.dart';
export 'convertible_widget.dart';
export 'expandable_fab.dart';
\ No newline at end of file
...@@ -6,6 +6,10 @@ ...@@ -6,6 +6,10 @@
#include "generated_plugin_registrant.h" #include "generated_plugin_registrant.h"
#include <open_file_linux/open_file_linux_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) { void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) open_file_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "OpenFileLinuxPlugin");
open_file_linux_plugin_register_with_registrar(open_file_linux_registrar);
} }
...@@ -3,6 +3,7 @@ ...@@ -3,6 +3,7 @@
# #
list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_PLUGIN_LIST
open_file_linux
) )
list(APPEND FLUTTER_FFI_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST
......
...@@ -5,6 +5,16 @@ ...@@ -5,6 +5,16 @@
import FlutterMacOS import FlutterMacOS
import Foundation import Foundation
import ffmpeg_kit_flutter_new
import file_picker
import open_file_mac
import path_provider_foundation
import shared_preferences_foundation
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FFmpegKitFlutterPlugin.register(with: registry.registrar(forPlugin: "FFmpegKitFlutterPlugin"))
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
OpenFilePlugin.register(with: registry.registrar(forPlugin: "OpenFilePlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
} }
# Generated by pub # Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile # See https://dart.dev/tools/pub/glossary#lockfile
packages: packages:
archive:
dependency: "direct main"
description:
name: archive
sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd"
url: "https://pub.dev"
source: hosted
version: "4.0.7"
async: async:
dependency: transitive dependency: transitive
description: description:
name: async name: async
sha256: d2872f9c19731c2e5f10444b14686eb7cc85c76274bd6c16e1816bff9a3bab63 sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.12.0" version: "2.13.0"
boolean_selector: boolean_selector:
dependency: transitive dependency: transitive
description: description:
...@@ -41,6 +49,22 @@ packages: ...@@ -41,6 +49,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.19.1" version: "1.19.1"
cross_file:
dependency: transitive
description:
name: cross_file
sha256: "7caf6a750a0c04effbb52a676dce9a4a592e10ad35c34d6d2d0e4811160d5670"
url: "https://pub.dev"
source: hosted
version: "0.3.4+2"
crypto:
dependency: transitive
description:
name: crypto
sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855"
url: "https://pub.dev"
source: hosted
version: "3.0.6"
cupertino_icons: cupertino_icons:
dependency: "direct main" dependency: "direct main"
description: description:
...@@ -49,14 +73,86 @@ packages: ...@@ -49,14 +73,86 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.8" version: "1.0.8"
dio:
dependency: "direct main"
description:
name: dio
sha256: "253a18bbd4851fecba42f7343a1df3a9a4c1d31a2c1b37e221086b4fa8c8dbc9"
url: "https://pub.dev"
source: hosted
version: "5.8.0+1"
dio_web_adapter:
dependency: transitive
description:
name: dio_web_adapter
sha256: "7586e476d70caecaf1686d21eee7247ea43ef5c345eab9e0cc3583ff13378d78"
url: "https://pub.dev"
source: hosted
version: "2.1.1"
fake_async: fake_async:
dependency: transitive dependency: transitive
description: description:
name: fake_async name: fake_async
sha256: "6a95e56b2449df2273fd8c45a662d6947ce1ebb7aafe80e550a3f68297f3cacc" sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
url: "https://pub.dev"
source: hosted
version: "1.3.3"
ffi:
dependency: transitive
description:
name: ffi
sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6"
url: "https://pub.dev"
source: hosted
version: "2.1.3"
ffmpeg_kit_flutter_android:
dependency: transitive
description:
name: ffmpeg_kit_flutter_android
sha256: "1bcd2e18169862d9782f019e3e83f92a41a12692deb25e0e767ecd44518c0f7e"
url: "https://pub.dev"
source: hosted
version: "1.4.0"
ffmpeg_kit_flutter_new:
dependency: "direct main"
description:
name: ffmpeg_kit_flutter_new
sha256: dbaf0f4963b08a034a4f787276c1e12efc36a56670693f1721e46f95554f0979
url: "https://pub.dev"
source: hosted
version: "1.6.1"
ffmpeg_kit_flutter_platform_interface:
dependency: transitive
description:
name: ffmpeg_kit_flutter_platform_interface
sha256: addf046ae44e190ad0101b2fde2ad909a3cd08a2a109f6106d2f7048b7abedee
url: "https://pub.dev"
source: hosted
version: "0.2.1"
file:
dependency: transitive
description:
name: file
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.3.2" version: "7.0.1"
file_picker:
dependency: "direct main"
description:
name: file_picker
sha256: "7423298f08f6fc8cce05792bae329f9a93653fc9c08712831b1a55540127995d"
url: "https://pub.dev"
source: hosted
version: "9.0.2"
fixnum:
dependency: transitive
description:
name: fixnum
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
url: "https://pub.dev"
source: hosted
version: "1.1.1"
flutter: flutter:
dependency: "direct main" dependency: "direct main"
description: flutter description: flutter
...@@ -70,19 +166,40 @@ packages: ...@@ -70,19 +166,40 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "5.0.0" version: "5.0.0"
flutter_plugin_android_lifecycle:
dependency: transitive
description:
name: flutter_plugin_android_lifecycle
sha256: "5a1e6fb2c0561958d7e4c33574674bda7b77caaca7a33b758876956f2902eea3"
url: "https://pub.dev"
source: hosted
version: "2.0.27"
flutter_test: flutter_test:
dependency: "direct dev" dependency: "direct dev"
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
flutter_web_plugins:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
http_parser:
dependency: transitive
description:
name: http_parser
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
url: "https://pub.dev"
source: hosted
version: "4.1.2"
leak_tracker: leak_tracker:
dependency: transitive dependency: transitive
description: description:
name: leak_tracker name: leak_tracker
sha256: c35baad643ba394b40aac41080300150a4f08fd0fd6a10378f8f7c6bc161acec sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "10.0.8" version: "10.0.9"
leak_tracker_flutter_testing: leak_tracker_flutter_testing:
dependency: transitive dependency: transitive
description: description:
...@@ -131,6 +248,78 @@ packages: ...@@ -131,6 +248,78 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.16.0" version: "1.16.0"
nested:
dependency: transitive
description:
name: nested
sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
open_file:
dependency: "direct main"
description:
name: open_file
sha256: d17e2bddf5b278cb2ae18393d0496aa4f162142ba97d1a9e0c30d476adf99c0e
url: "https://pub.dev"
source: hosted
version: "3.5.10"
open_file_android:
dependency: transitive
description:
name: open_file_android
sha256: "58141fcaece2f453a9684509a7275f231ac0e3d6ceb9a5e6de310a7dff9084aa"
url: "https://pub.dev"
source: hosted
version: "1.0.6"
open_file_ios:
dependency: transitive
description:
name: open_file_ios
sha256: "02996f01e5f6863832068e97f8f3a5ef9b613516db6897f373b43b79849e4d07"
url: "https://pub.dev"
source: hosted
version: "1.0.3"
open_file_linux:
dependency: transitive
description:
name: open_file_linux
sha256: d189f799eecbb139c97f8bc7d303f9e720954fa4e0fa1b0b7294767e5f2d7550
url: "https://pub.dev"
source: hosted
version: "0.0.5"
open_file_mac:
dependency: transitive
description:
name: open_file_mac
sha256: "1440b1e37ceb0642208cfeb2c659c6cda27b25187a90635c9d1acb7d0584d324"
url: "https://pub.dev"
source: hosted
version: "1.0.3"
open_file_platform_interface:
dependency: transitive
description:
name: open_file_platform_interface
sha256: "101b424ca359632699a7e1213e83d025722ab668b9fd1412338221bf9b0e5757"
url: "https://pub.dev"
source: hosted
version: "1.0.3"
open_file_web:
dependency: transitive
description:
name: open_file_web
sha256: e3dbc9584856283dcb30aef5720558b90f88036360bd078e494ab80a80130c4f
url: "https://pub.dev"
source: hosted
version: "0.0.4"
open_file_windows:
dependency: transitive
description:
name: open_file_windows
sha256: d26c31ddf935a94a1a3aa43a23f4fff8a5ff4eea395fe7a8cb819cf55431c875
url: "https://pub.dev"
source: hosted
version: "0.0.3"
path: path:
dependency: transitive dependency: transitive
description: description:
...@@ -139,6 +328,190 @@ packages: ...@@ -139,6 +328,190 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.9.1" version: "1.9.1"
path_provider:
dependency: "direct main"
description:
name: path_provider
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
url: "https://pub.dev"
source: hosted
version: "2.1.5"
path_provider_android:
dependency: transitive
description:
name: path_provider_android
sha256: d0d310befe2c8ab9e7f393288ccbb11b60c019c6b5afc21973eeee4dda2b35e9
url: "https://pub.dev"
source: hosted
version: "2.2.17"
path_provider_foundation:
dependency: transitive
description:
name: path_provider_foundation
sha256: "4843174df4d288f5e29185bd6e72a6fbdf5a4a4602717eed565497429f179942"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
path_provider_linux:
dependency: transitive
description:
name: path_provider_linux
sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279
url: "https://pub.dev"
source: hosted
version: "2.2.1"
path_provider_platform_interface:
dependency: transitive
description:
name: path_provider_platform_interface
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
path_provider_windows:
dependency: transitive
description:
name: path_provider_windows
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
url: "https://pub.dev"
source: hosted
version: "2.3.0"
permission_handler:
dependency: "direct main"
description:
name: permission_handler
sha256: "2d070d8684b68efb580a5997eb62f675e8a885ef0be6e754fb9ef489c177470f"
url: "https://pub.dev"
source: hosted
version: "12.0.0+1"
permission_handler_android:
dependency: transitive
description:
name: permission_handler_android
sha256: "1e3bc410ca1bf84662104b100eb126e066cb55791b7451307f9708d4007350e6"
url: "https://pub.dev"
source: hosted
version: "13.0.1"
permission_handler_apple:
dependency: transitive
description:
name: permission_handler_apple
sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023
url: "https://pub.dev"
source: hosted
version: "9.4.7"
permission_handler_html:
dependency: transitive
description:
name: permission_handler_html
sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24"
url: "https://pub.dev"
source: hosted
version: "0.1.3+5"
permission_handler_platform_interface:
dependency: transitive
description:
name: permission_handler_platform_interface
sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878
url: "https://pub.dev"
source: hosted
version: "4.3.0"
permission_handler_windows:
dependency: transitive
description:
name: permission_handler_windows
sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e"
url: "https://pub.dev"
source: hosted
version: "0.2.1"
platform:
dependency: transitive
description:
name: platform
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
url: "https://pub.dev"
source: hosted
version: "3.1.6"
plugin_platform_interface:
dependency: transitive
description:
name: plugin_platform_interface
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
url: "https://pub.dev"
source: hosted
version: "2.1.8"
posix:
dependency: transitive
description:
name: posix
sha256: f0d7856b6ca1887cfa6d1d394056a296ae33489db914e365e2044fdada449e62
url: "https://pub.dev"
source: hosted
version: "6.0.2"
provider:
dependency: "direct main"
description:
name: provider
sha256: c8a055ee5ce3fd98d6fc872478b03823ffdb448699c6ebdbbc71d59b596fd48c
url: "https://pub.dev"
source: hosted
version: "6.1.2"
shared_preferences:
dependency: "direct main"
description:
name: shared_preferences
sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5"
url: "https://pub.dev"
source: hosted
version: "2.5.3"
shared_preferences_android:
dependency: transitive
description:
name: shared_preferences_android
sha256: "20cbd561f743a342c76c151d6ddb93a9ce6005751e7aa458baad3858bfbfb6ac"
url: "https://pub.dev"
source: hosted
version: "2.4.10"
shared_preferences_foundation:
dependency: transitive
description:
name: shared_preferences_foundation
sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03"
url: "https://pub.dev"
source: hosted
version: "2.5.4"
shared_preferences_linux:
dependency: transitive
description:
name: shared_preferences_linux
sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
shared_preferences_platform_interface:
dependency: transitive
description:
name: shared_preferences_platform_interface
sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
shared_preferences_web:
dependency: transitive
description:
name: shared_preferences_web
sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
url: "https://pub.dev"
source: hosted
version: "2.4.3"
shared_preferences_windows:
dependency: transitive
description:
name: shared_preferences_windows
sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
sky_engine: sky_engine:
dependency: transitive dependency: transitive
description: flutter description: flutter
...@@ -152,6 +525,14 @@ packages: ...@@ -152,6 +525,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.10.1" version: "1.10.1"
sprintf:
dependency: transitive
description:
name: sprintf
sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23"
url: "https://pub.dev"
source: hosted
version: "7.0.0"
stack_trace: stack_trace:
dependency: transitive dependency: transitive
description: description:
...@@ -192,6 +573,22 @@ packages: ...@@ -192,6 +573,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.4" version: "0.7.4"
typed_data:
dependency: transitive
description:
name: typed_data
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
url: "https://pub.dev"
source: hosted
version: "1.4.0"
uuid:
dependency: "direct main"
description:
name: uuid
sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff
url: "https://pub.dev"
source: hosted
version: "4.5.1"
vector_math: vector_math:
dependency: transitive dependency: transitive
description: description:
...@@ -204,10 +601,34 @@ packages: ...@@ -204,10 +601,34 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: vm_service name: vm_service
sha256: "0968250880a6c5fe7edc067ed0a13d4bae1577fe2771dcf3010d52c4a9d3ca14" sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02
url: "https://pub.dev"
source: hosted
version: "15.0.0"
web:
dependency: transitive
description:
name: web
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
url: "https://pub.dev"
source: hosted
version: "1.1.1"
win32:
dependency: transitive
description:
name: win32
sha256: daf97c9d80197ed7b619040e86c8ab9a9dad285e7671ee7390f9180cc828a51e
url: "https://pub.dev"
source: hosted
version: "5.10.1"
xdg_directories:
dependency: transitive
description:
name: xdg_directories
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "14.3.1" version: "1.1.0"
sdks: sdks:
dart: ">=3.7.0 <4.0.0" dart: ">=3.7.0-0 <4.0.0"
flutter: ">=3.18.0-18.0.pre.54" flutter: ">=3.27.0"
...@@ -19,7 +19,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev ...@@ -19,7 +19,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
version: 1.0.0+1 version: 1.0.0+1
environment: environment:
sdk: ^3.7.0 sdk: ^3.6.2
# Dependencies specify other packages that your package needs in order to work. # Dependencies specify other packages that your package needs in order to work.
# To automatically upgrade your package dependencies to the latest versions # To automatically upgrade your package dependencies to the latest versions
...@@ -34,6 +34,16 @@ dependencies: ...@@ -34,6 +34,16 @@ dependencies:
# The following adds the Cupertino Icons font to your application. # The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons. # Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.8 cupertino_icons: ^1.0.8
provider: ^6.1.2
file_picker: ^9.0.2
uuid: ^4.5.1
path_provider: ^2.1.5
permission_handler: ^12.0.0+1
open_file: ^3.5.10
ffmpeg_kit_flutter_new: ^1.6.1
shared_preferences: ^2.5.3
archive: ^4.0.7
dio: ^5.8.0+1
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
......
...@@ -6,6 +6,9 @@ ...@@ -6,6 +6,9 @@
#include "generated_plugin_registrant.h" #include "generated_plugin_registrant.h"
#include <permission_handler_windows/permission_handler_windows_plugin.h>
void RegisterPlugins(flutter::PluginRegistry* registry) { void RegisterPlugins(flutter::PluginRegistry* registry) {
PermissionHandlerWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
} }
...@@ -3,6 +3,7 @@ ...@@ -3,6 +3,7 @@
# #
list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_PLUGIN_LIST
permission_handler_windows
) )
list(APPEND FLUTTER_FFI_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment