Patrones de diseño con Flutter: 12 — Visitor

Paolo Pinto
9 min readApr 26, 2024

--

tambien llamado visitante

Este patron nace de la necesidad de implementar una característica cuando ya nuestro código tiene un gran volumen y arquitectura. Esto ya que nuestro equipo no esta de acuerdo en hacerle cambios al código fuente en producción. Entonces ¿Que opciones tendriamos al crear nuevas características?

Imagina que te dan una tarea en tu app para exportar información en XML. Tu información es de geolocalización y la tienes estructurada en grafos(Casa, edificio, restaurante). Entonces decides implementar en los nodos del grafo el metodoDeExportacionXML.

Esta es una gran solución, pero de pronto el equipo decide no alterar ese codigo y cuestionar tu solución ya que carecia de sentido(según ellos). Ahi es donde surge el patrón visitor.

¿Que podemos hacer con el Patrón Visitor?

Visitor es un patrón de diseño de comportamiento que te permite separar algoritmos de los objetos sobre los que operan.

El patrón Visitor sugiere que coloques el nuevo comportamiento en una clase separada llamada Visitor(Visitante), en lugar de integrarlo en clases existentes. La tarea pasa ahora a uno de los métodos del visitante como argumento, y este accede a toda la información dentro del objeto.

Visitor: separar algoritmos sobre los objetos que los operan.

Se te puede presentar que el comportamiento puede ejecutarse sobre objetos de clases diferentes. Por lo tanto, la clase visitante puede definir un grupo de métodos en lugar de uno solo, y cada uno de ellos podría tomar argumentos de distintos tipos, así:

class ExportVisitor implements Visitor is
method doForCity(City c) { ... }
method doForIndustry(Industry f) { ... }
method doForSightSeeing(SightSeeing ss) { ... }

¿Como implementamos Visitor en Fluter?

Lo veremos mejor ejemplificado en este diagrama:

Helpers:

  • Nos ayudara a convertir de bytes a String.
class FileSizeConverter {
const FileSizeConverter._();
static String bytesToString(int bytes) {
final sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
var len = bytes.toDouble();
var order = 0;
while (len >= 1024 && order++ < sizes.length - 1) {
len /= 1024;
}
return '${len.toStringAsFixed(2)} ${sizes[order]}';
}
}
  • Nos ayudara a dar formato a un string(indentacion y nuevo salto de linea)
extension FormattingExtension on String {
String indentAndAddNewLine(int nTabs) => '${'\t' * nTabs}$this\n';
}

IVisitor:

La interfaz Visitor declara un grupo de métodos visitantes que pueden tomar elementos concretos de una estructura de objetos como argumentos. Estos métodos pueden tener los mismos nombres si el programa está escrito en un lenguaje que soporte la sobrecarga, pero los tipos de sus parámetros deben ser diferentes.

abstract interface class IVisitor {
String getTitle();
String visitDirectory(Directory directory);
String visitAudioFile(AudioFile file);
String visitImageFile(ImageFile file);
String visitTextFile(TextFile file);
String visitVideoFile(VideoFile file);
}

IFile:

La interfaz Element declara un método para “aceptar” visitantes. Este método deberá contar con un parámetro declarado con el tipo de la interfaz visitante.

abstract interface class IFile {
int getSize();
Widget render(BuildContext context);
String accept(IVisitor visitor);
}

Directory y File(con Herencia) :

Cada ConcreteElement debe implementar el método de accept(). El propósito de este método es redirigir la llamada al método adecuado del visitante correspondiente a la clase de elemento actual. Piensa que, aunque una clase base de elemento implemente este método, todas las subclases deben sobrescribir este método en sus propias clases e invocar el método adecuado en el objeto visitante.

  • File:
import 'package:flutter/material.dart';

import '../data/helpers/file_size_converter.dart';
import 'interface_file.dart';

abstract class File extends StatelessWidget implements IFile {
final String title;
final String fileExtension;
final int size;
final IconData icon;
const File({
required this.title,
required this.fileExtension,
required this.size,
required this.icon,
});
@override
int getSize() => size;
@override
Widget render(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(left: 16),
child: ListTile(
title: Text(
'$title.$fileExtension',
style: Theme.of(context).textTheme.bodyLarge,
),
leading: Icon(icon),
trailing: Text(
FileSizeConverter.bytesToString(size),
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(color: Colors.black54),
),
dense: true,
),
);
}
@override
Widget build(BuildContext context) => render(context);
}
  • File hereda hacia AudioFile | ImageFile | VideoFile | TextFile:
// AudioFile
class AudioFile extends File {
const AudioFile({
required this.albumTitle,
required super.title,
required super.fileExtension,
required super.size,
}) : super(icon: Icons.music_note);
final String albumTitle;
@override
String accept(IVisitor visitor) => visitor.visitAudioFile(this);
}
// ImageFile
class ImageFile extends File {
const ImageFile({
required this.resolution,
required super.title,
required super.fileExtension,
required super.size,
}) : super(icon: Icons.image);
final String resolution;
@override
String accept(IVisitor visitor) => visitor.visitImageFile(this);
}
// TextFile
class TextFile extends File {
const TextFile({
required this.content,
required super.title,
required super.fileExtension,
required super.size,
}) : super(icon: Icons.description);
final String content;
@override
String accept(IVisitor visitor) => visitor.visitTextFile(this);
}
// VideoFile
class VideoFile extends File {
const VideoFile({
required this.directedBy,
required super.title,
required super.fileExtension,
required super.size,
}) : super(icon: Icons.movie);
final String directedBy;
@override
String accept(IVisitor visitor) => visitor.visitVideoFile(this);
}
  • Directory:
class Directory extends StatelessWidget implements IFile {
final String title;
final int level;
final bool isInitiallyExpanded;
final List<IFile> _files = [];
List<IFile> get files => _files;
Directory({
required this.title,
required this.level,
this.isInitiallyExpanded = false,
});
void addFile(IFile file) => _files.add(file);
@override
int getSize() {
var sum = 0;
for (final file in _files) {
sum += file.getSize();
}
return sum;
}
@override
Widget render(BuildContext context) {
return Theme(
data: ThemeData(
expansionTileTheme: Theme.of(context)
.expansionTileTheme
.copyWith(iconColor: Colors.black, textColor: Colors.black),
),
child: Padding(
padding: const EdgeInsets.only(left: LayoutConstants.paddingS),
child: ExpansionTile(
leading: const Icon(Icons.folder),
title: Text('$title (${FileSizeConverter.bytesToString(getSize())})'),
initiallyExpanded: isInitiallyExpanded,
children: _files.map((IFile file) => file.render(context)).toList(),
),
),
);
}
@override
Widget build(BuildContext context) => render(context);
@override
String accept(IVisitor visitor) => visitor.visitDirectory(this);
}

XMLVisitor | HumanReadableVisitor:

Cada ConcreteVisitor implementa varias versiones de los mismos comportamientos, personalizadas para las distintas clases de elemento concreto.

  • HumanReadableVisitor: Le da en un formato legible para cada humano.
class HumanReadableVisitor implements IVisitor {
const HumanReadableVisitor();

@override
String getTitle() => 'Export as text';

@override
String visitAudioFile(AudioFile file) {
final fileInfo = <String, String>{
'Type': 'Audio',
'Album': file.albumTitle,
'Extension': file.fileExtension,
'Size': FileSizeConverter.bytesToString(file.getSize()),
};

return _formatFile(file.title, fileInfo);
}

@override
String visitDirectory(Directory directory) {
final buffer = StringBuffer();

for (final file in directory.files) {
buffer.write(file.accept(this));
}

return buffer.toString();
}

@override
String visitImageFile(ImageFile file) {
final fileInfo = <String, String>{
'Type': 'Image',
'Resolution': file.resolution,
'Extension': file.fileExtension,
'Size': FileSizeConverter.bytesToString(file.getSize()),
};

return _formatFile(file.title, fileInfo);
}

@override
String visitTextFile(TextFile file) {
final fileContentPreview = file.content.length > 30
? '${file.content.substring(0, 30)}...'
: file.content;

final fileInfo = <String, String>{
'Type': 'Text',
'Preview': fileContentPreview,
'Extension': file.fileExtension,
'Size': FileSizeConverter.bytesToString(file.getSize()),
};

return _formatFile(file.title, fileInfo);
}

@override
String visitVideoFile(VideoFile file) {
final fileInfo = <String, String>{
'Type': 'Video',
'Directed by': file.directedBy,
'Extension': file.fileExtension,
'Size': FileSizeConverter.bytesToString(file.getSize()),
};

return _formatFile(file.title, fileInfo);
}

String _formatFile(String title, Map<String, String> fileInfo) {
final buffer = StringBuffer();

buffer.write('$title:\n');

for (final entry in fileInfo.entries) {
buffer.write('${entry.key}: ${entry.value}'.indentAndAddNewLine(2));
}

return buffer.toString();
}
}
  • XMLVisitor:
class XmlVisitor implements IVisitor {
const XmlVisitor();

@override
String getTitle() => 'Export as XML';

@override
String visitAudioFile(AudioFile file) {
final fileInfo = <String, String>{
'title': file.title,
'album': file.albumTitle,
'extension': file.fileExtension,
'size': FileSizeConverter.bytesToString(file.getSize()),
};

return _formatFile('audio', fileInfo);
}

@override
String visitDirectory(Directory directory) {
final isRootDirectory = directory.level == 0;
final buffer = StringBuffer();

if (isRootDirectory) buffer.write('<files>\n');

for (final file in directory.files) {
buffer.write(file.accept(this));
}

if (isRootDirectory) buffer.write('</files>\n');

return buffer.toString();
}

@override
String visitImageFile(ImageFile file) {
final fileInfo = <String, String>{
'title': file.title,
'resolution': file.resolution,
'extension': file.fileExtension,
'size': FileSizeConverter.bytesToString(file.getSize()),
};

return _formatFile('image', fileInfo);
}

@override
String visitTextFile(TextFile file) {
final fileContentPreview = file.content.length > 30
? '${file.content.substring(0, 30)}...'
: file.content;

final fileInfo = <String, String>{
'title': file.title,
'preview': fileContentPreview,
'extension': file.fileExtension,
'size': FileSizeConverter.bytesToString(file.getSize()),
};

return _formatFile('text', fileInfo);
}

@override
String visitVideoFile(VideoFile file) {
final fileInfo = <String, String>{
'title': file.title,
'directed_by': file.directedBy,
'extension': file.fileExtension,
'size': FileSizeConverter.bytesToString(file.getSize()),
};

return _formatFile('video', fileInfo);
}

String _formatFile(String type, Map<String, String> fileInfo) {
final buffer = StringBuffer();

buffer.write('<$type>'.indentAndAddNewLine(2));

for (final entry in fileInfo.entries) {
buffer.write(
'<${entry.key}>${entry.value}</${entry.key}>'.indentAndAddNewLine(4),
);
}

buffer.write('</$type>'.indentAndAddNewLine(2));

return buffer.toString();
}
}

CodigoCliente:

En este ejemplo se intenta reflejar que al exportar información de archivos en modo modal mediante el método showFilesDialog(), el widget no se preocupa por el visitante concreto, este acepta que sea de la interfaz IVisitor. El visitante seleccionado simplemente se aplica a toda la estructura del archivo pasándolo como parámetro al método aceptar(), recuperando así la estructura de los archivos formateados como texto y proporcionándola al modal FilesDialog abierto.

Extra: PlatformButton.

import 'dart:io';

import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';

class PlatformButton extends StatelessWidget {
final String text;
final Color materialColor;
final Color materialTextColor;
final VoidCallback? onPressed;

const PlatformButton({
required this.text,
required this.materialColor,
required this.materialTextColor,
this.onPressed,
});

@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(4.0),
child: kIsWeb || Platform.isAndroid
? MaterialButton(
color: materialColor,
textColor: materialTextColor,
disabledColor: Colors.grey,
disabledTextColor: Colors.white,
onPressed: onPressed,
child: Text(text, textAlign: TextAlign.center),
)
: CupertinoButton(
color: Colors.black,
onPressed: onPressed,
child: Text(text, textAlign: TextAlign.center),
),
);
}
}

Extra: FilesDialog

import 'package:flutter/material.dart';
import 'platform_button.dart';

class FilesDialog extends StatelessWidget {
const FilesDialog({
required this.filesText,
});

final String filesText;

@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Files'),
content: ScrollConfiguration(
behavior: const ScrollBehavior(),
child: SingleChildScrollView(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Text(filesText),
),
),
),
actions: <Widget>[
PlatformButton(
materialColor: Colors.black,
materialTextColor: Colors.white,
onPressed: Navigator.of(context).pop,
text: 'Close',
),
],
);
}
}

Extra: VisitorSelection

import 'package:flutter/material.dart';
import '../domain/interface_visitor.dart';

class FilesVisitorSelection extends StatelessWidget {
const FilesVisitorSelection({
required this.visitorsList,
required this.selectedIndex,
required this.onChanged,
});

final List<IVisitor> visitorsList;
final int selectedIndex;
final ValueSetter<int?> onChanged;

@override
Widget build(BuildContext context) {
return Column(
children: <Widget>[
for (final (i, visitor) in visitorsList.indexed)
RadioListTile(
title: Text(visitor.getTitle()),
value: i,
groupValue: selectedIndex,
selected: i == selectedIndex,
activeColor: Colors.black,
onChanged: onChanged,
),
],
);
}
}

VisitorExample:

class VisitorExample extends StatefulWidget {
const VisitorExample();

@override
_VisitorExampleState createState() => _VisitorExampleState();
}

class _VisitorExampleState extends State<VisitorExample> {
final visitorsList = const [HumanReadableVisitor(), XmlVisitor()];

late final IFile _rootDirectory;
var _selectedVisitorIndex = 0;

@override
void initState() {
super.initState();

_rootDirectory = _buildMediaDirectory();
}

IFile _buildMediaDirectory() {
final musicDirectory = Directory(title: 'Music', level: 1)
..addFile(
const AudioFile(
title: 'Darude - Sandstorm',
albumTitle: 'Before the Storm',
fileExtension: 'mp3',
size: 2612453,
),
)
..addFile(
const AudioFile(
title: 'Toto - Africa',
albumTitle: 'Toto IV',
fileExtension: 'mp3',
size: 3219811,
),
)
..addFile(
const AudioFile(
title: 'Bag Raiders - Shooting Stars',
albumTitle: 'Bag Raiders',
fileExtension: 'mp3',
size: 3811214,
),
);

final moviesDirectory = Directory(title: 'Movies', level: 1)
..addFile(
const VideoFile(
title: 'The Matrix',
directedBy: 'The Wachowskis',
fileExtension: 'avi',
size: 951495532,
),
)
..addFile(
const VideoFile(
title: 'Pulp Fiction',
directedBy: 'Quentin Tarantino',
fileExtension: 'mp4',
size: 1251495532,
),
);

final catPicturesDirectory = Directory(title: 'Cats', level: 2)
..addFile(
const ImageFile(
title: 'Cat 1',
resolution: '640x480px',
fileExtension: 'jpg',
size: 844497,
),
)
..addFile(
const ImageFile(
title: 'Cat 2',
resolution: '1280x720px',
fileExtension: 'jpg',
size: 975363,
),
)
..addFile(
const ImageFile(
title: 'Cat 3',
resolution: '1920x1080px',
fileExtension: 'png',
size: 1975363,
),
);

final picturesDirectory = Directory(title: 'Pictures', level: 1)
..addFile(catPicturesDirectory)
..addFile(
const ImageFile(
title: 'Not a cat',
resolution: '2560x1440px',
fileExtension: 'png',
size: 2971361,
),
);
final mediaDirectory = Directory(
title: 'Media',
level: 0,
isInitiallyExpanded: true,
)
..addFile(musicDirectory)
..addFile(moviesDirectory)
..addFile(picturesDirectory)
..addFile(Directory(title: 'New Folder', level: 1))
..addFile(
const TextFile(
title: 'Nothing suspicious there',
content: 'Just a normal text file without any sensitive information.',
fileExtension: 'txt',
size: 430791,
),
)
..addFile(
const TextFile(
title: 'TeamTrees',
content:
'Team Trees, also known as #teamtrees, is a collaborative fundraiser that managed to raise 20 million U.S. dollars before 2020 to plant 20 million trees.',
fileExtension: 'txt',
size: 1042,
),
);

return mediaDirectory;
}

void _setSelectedVisitorIndex(int? index) {
if (index == null) return;

setState(() => _selectedVisitorIndex = index);
}

void _showFilesDialog() {
final selectedVisitor = visitorsList[_selectedVisitorIndex];
final filesText = _rootDirectory.accept(selectedVisitor);

showDialog<void>(
context: context,
barrierDismissible: false,
builder: (_) => FilesDialog(filesText: filesText),
);
}

@override
Widget build(BuildContext context) {
return ScrollConfiguration(
behavior: const ScrollBehavior(),
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(
horizontal: LayoutConstants.paddingL,
),
child: Column(
children: [
FilesVisitorSelection(
visitorsList: visitorsList,
selectedIndex: _selectedVisitorIndex,
onChanged: _setSelectedVisitorIndex,
),
PlatformButton(
materialColor: Colors.black,
materialTextColor: Colors.white,
onPressed: _showFilesDialog,
text: 'Export files',
),
const SizedBox(height: LayoutConstants.spaceL),
_rootDirectory.render(context),
],
),
),
);
}
}

y YA!

Otros articulos para esta serie

Creacionales:

Estructurales:

De Comportamiento:

Tu contribución

👏 ¡Presiona el botón de aplaudir a continuación para mostrar tu apoyo y motivarme a escribir mejor!
💬 Deje una respuesta a este artículo brindando sus ideas, comentarios o deseos para la serie.
📢 Comparte este artículo con tus amigos y colegas en las redes sociales.
➕ Sígueme en Medium.
⭐ Ve los ejemplos prácticos desde mi canal en Youtube: Pacha Code

--

--