Commit b6b4f348 by Diego Pérez Peña

ConVertex terminado

parents 91c85acb 42e26cb7
......@@ -8,7 +8,7 @@ plugins {
android {
namespace = "com.example.prueba_multimedia"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
ndkVersion = "27.0.12077973"
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
......@@ -24,7 +24,7 @@ android {
applicationId = "com.example.prueba_multimedia"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
minSdk = 24
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
......
......@@ -4,4 +4,8 @@
to allow setting breakpoints, to provide hot reload, etc.
-->
<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>
import 'package:flutter/material.dart';
import 'convertex_prototipo_app.dart';
import 'package:prueba_multimedia/widgets/widgets.dart';
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 '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: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 {
const PaginaPrincipal({super.key});
@override
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 '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: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 {
const ConvertexPrototipoApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'ConVertex',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple)
),
......@@ -18,7 +22,13 @@ class ConvertexPrototipoApp extends StatelessWidget {
)
),
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 @@
#include "generated_plugin_registrant.h"
#include <open_file_linux/open_file_linux_plugin.h>
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 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
open_file_linux
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST
......
......@@ -5,6 +5,16 @@
import FlutterMacOS
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) {
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"))
}
......@@ -19,7 +19,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
version: 1.0.0+1
environment:
sdk: ^3.7.0
sdk: ^3.6.2
# Dependencies specify other packages that your package needs in order to work.
# To automatically upgrade your package dependencies to the latest versions
......@@ -34,6 +34,16 @@ dependencies:
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
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:
flutter_test:
......
......@@ -6,6 +6,9 @@
#include "generated_plugin_registrant.h"
#include <permission_handler_windows/permission_handler_windows_plugin.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
PermissionHandlerWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
}
......@@ -3,6 +3,7 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
permission_handler_windows
)
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