mirror of
https://github.com/immich-app/immich.git
synced 2026-01-26 03:14:39 -08:00
feat: mobile filtering
This commit is contained in:
@@ -8,6 +8,7 @@ import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/constants/filters.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/asset_edit.model.dart';
|
||||
import 'package:immich_mobile/domain/models/exif.model.dart';
|
||||
@@ -48,14 +49,16 @@ class _DriftEditImagePageState extends ConsumerState<DriftEditImagePage> with Ti
|
||||
int _rotationAngle = 0;
|
||||
bool _flipHorizontal = false;
|
||||
bool _flipVertical = false;
|
||||
|
||||
double? aspectRatio;
|
||||
ColorFilter? _colorFilter;
|
||||
double? _aspectRatio;
|
||||
|
||||
late final originalWidth = widget.exifInfo.isFlipped ? widget.exifInfo.height : widget.exifInfo.width;
|
||||
late final originalHeight = widget.exifInfo.isFlipped ? widget.exifInfo.width : widget.exifInfo.height;
|
||||
|
||||
bool isEditing = false;
|
||||
|
||||
String selectedSegment = 'transform';
|
||||
|
||||
void initEditor() {
|
||||
final existingCrop = widget.edits.firstWhereOrNull((edit) => edit.action == AssetEditAction.crop);
|
||||
|
||||
@@ -198,6 +201,41 @@ class _DriftEditImagePageState extends ConsumerState<DriftEditImagePage> with Ti
|
||||
});
|
||||
}
|
||||
|
||||
void _applyAspectRatio(double? ratio) {
|
||||
setState(() {
|
||||
cropController.aspectRatio = ratio;
|
||||
_aspectRatio = ratio;
|
||||
});
|
||||
}
|
||||
|
||||
void _applyFilter(ColorFilter? filter) {
|
||||
setState(() {
|
||||
_colorFilter = filter;
|
||||
});
|
||||
}
|
||||
|
||||
void _resetEdits() {
|
||||
setState(() {
|
||||
cropController.aspectRatio = null;
|
||||
cropController.crop = const Rect.fromLTRB(0, 0, 1, 1);
|
||||
_rotationAnimationDuration = const Duration(milliseconds: 250);
|
||||
_rotationAngle = 0;
|
||||
_flipHorizontal = false;
|
||||
_flipVertical = false;
|
||||
_colorFilter = null;
|
||||
_aspectRatio = null;
|
||||
});
|
||||
}
|
||||
|
||||
bool get hasEdits {
|
||||
final isCropped = cropController.crop != const Rect.fromLTRB(0, 0, 1, 1);
|
||||
final isRotated = (_rotationAngle % 360 + 360) % 360 != 0;
|
||||
final isFlipped = _flipHorizontal || _flipVertical;
|
||||
final isFiltered = _colorFilter != null;
|
||||
|
||||
return isCropped || isRotated || isFlipped || isFiltered;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Theme(
|
||||
@@ -221,18 +259,16 @@ class _DriftEditImagePageState extends ConsumerState<DriftEditImagePage> with Ti
|
||||
backgroundColor: Colors.black,
|
||||
body: SafeArea(
|
||||
bottom: false,
|
||||
child: LayoutBuilder(
|
||||
builder: (BuildContext context, BoxConstraints constraints) {
|
||||
// Calculate the bounding box size needed for the rotated container
|
||||
final baseWidth = constraints.maxWidth * 0.9;
|
||||
final baseHeight = constraints.maxHeight * 0.8;
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: LayoutBuilder(
|
||||
builder: (BuildContext context, BoxConstraints constraints) {
|
||||
// Calculate the bounding box size needed for the rotated container
|
||||
final baseWidth = constraints.maxWidth * 0.9;
|
||||
final baseHeight = constraints.maxHeight * 0.95;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: constraints.maxWidth,
|
||||
height: constraints.maxHeight * 0.7,
|
||||
child: Center(
|
||||
return Center(
|
||||
child: AnimatedRotation(
|
||||
turns: _rotationAngle / 360,
|
||||
duration: _rotationAnimationDuration,
|
||||
@@ -245,185 +281,103 @@ class _DriftEditImagePageState extends ConsumerState<DriftEditImagePage> with Ti
|
||||
padding: const EdgeInsets.all(10),
|
||||
width: (_rotationAngle % 180 == 0) ? baseWidth : baseHeight,
|
||||
height: (_rotationAngle % 180 == 0) ? baseHeight : baseWidth,
|
||||
child: CropImage(controller: cropController, image: widget.image, gridColor: Colors.white),
|
||||
child: FutureBuilder(
|
||||
future: resolveImage(widget.image.image),
|
||||
builder: (context, data) {
|
||||
if (!data.hasData) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
return CropImage(
|
||||
controller: cropController,
|
||||
image: widget.image,
|
||||
gridColor: Colors.white,
|
||||
overlayPainter: MatrixAdjustmentPainter(image: data.data!, filter: _colorFilter),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
AnimatedSize(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
curve: Curves.easeInOut,
|
||||
alignment: Alignment.bottomCenter,
|
||||
clipBehavior: Clip.none,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: ref.watch(immichThemeProvider).dark.surface,
|
||||
borderRadius: const BorderRadius.only(topLeft: Radius.circular(20), topRight: Radius.circular(20)),
|
||||
),
|
||||
Expanded(
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: context.scaffoldBackgroundColor,
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(20),
|
||||
topRight: Radius.circular(20),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
AnimatedCrossFade(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
firstCurve: Curves.easeInOut,
|
||||
secondCurve: Curves.easeInOut,
|
||||
sizeCurve: Curves.easeInOut,
|
||||
crossFadeState: selectedSegment == 'transform'
|
||||
? CrossFadeState.showFirst
|
||||
: CrossFadeState.showSecond,
|
||||
firstChild: _TransformControls(
|
||||
onRotateLeft: _rotateLeft,
|
||||
onRotateRight: _rotateRight,
|
||||
onFlipHorizontal: _flipHorizontally,
|
||||
onFlipVertical: _flipVertically,
|
||||
onAspectRatioSelected: _applyAspectRatio,
|
||||
aspectRatio: _aspectRatio,
|
||||
),
|
||||
secondChild: _FilterControls(
|
||||
filter: _colorFilter,
|
||||
previewImage: widget.image,
|
||||
onApplyFilter: _applyFilter,
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 36, left: 24, right: 24),
|
||||
child: Row(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 20, right: 20, top: 20, bottom: 10),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
ImmichIconButton(
|
||||
icon: Icons.rotate_left,
|
||||
variant: ImmichVariant.ghost,
|
||||
color: ImmichColor.secondary,
|
||||
onPressed: _rotateLeft,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ImmichIconButton(
|
||||
icon: Icons.rotate_right,
|
||||
variant: ImmichVariant.ghost,
|
||||
color: ImmichColor.secondary,
|
||||
onPressed: _rotateRight,
|
||||
),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
ImmichIconButton(
|
||||
icon: Icons.flip,
|
||||
variant: ImmichVariant.ghost,
|
||||
color: _flipHorizontal ? ImmichColor.primary : ImmichColor.secondary,
|
||||
onPressed: _flipHorizontally,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Transform.rotate(
|
||||
angle: pi / 2,
|
||||
child: ImmichIconButton(
|
||||
icon: Icons.flip,
|
||||
variant: ImmichVariant.ghost,
|
||||
color: _flipVertical ? ImmichColor.primary : ImmichColor.secondary,
|
||||
onPressed: _flipVertically,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Row(
|
||||
spacing: 12,
|
||||
children: <Widget>[
|
||||
_AspectRatioButton(
|
||||
cropController: cropController,
|
||||
currentAspectRatio: aspectRatio,
|
||||
ratio: null,
|
||||
label: 'Free',
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
aspectRatio = null;
|
||||
cropController.aspectRatio = null;
|
||||
});
|
||||
},
|
||||
),
|
||||
_AspectRatioButton(
|
||||
cropController: cropController,
|
||||
currentAspectRatio: aspectRatio,
|
||||
ratio: 1.0,
|
||||
label: '1:1',
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
aspectRatio = 1.0;
|
||||
cropController.aspectRatio = 1.0;
|
||||
});
|
||||
},
|
||||
),
|
||||
_AspectRatioButton(
|
||||
cropController: cropController,
|
||||
currentAspectRatio: aspectRatio,
|
||||
ratio: 16.0 / 9.0,
|
||||
label: '16:9',
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
aspectRatio = 16.0 / 9.0;
|
||||
cropController.aspectRatio = 16.0 / 9.0;
|
||||
});
|
||||
},
|
||||
),
|
||||
_AspectRatioButton(
|
||||
cropController: cropController,
|
||||
currentAspectRatio: aspectRatio,
|
||||
ratio: 3.0 / 2.0,
|
||||
label: '3:2',
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
aspectRatio = 3.0 / 2.0;
|
||||
cropController.aspectRatio = 3.0 / 2.0;
|
||||
});
|
||||
},
|
||||
),
|
||||
_AspectRatioButton(
|
||||
cropController: cropController,
|
||||
currentAspectRatio: aspectRatio,
|
||||
ratio: 7.0 / 5.0,
|
||||
label: '7:5',
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
aspectRatio = 7.0 / 5.0;
|
||||
cropController.aspectRatio = 7.0 / 5.0;
|
||||
});
|
||||
},
|
||||
),
|
||||
_AspectRatioButton(
|
||||
cropController: cropController,
|
||||
currentAspectRatio: aspectRatio,
|
||||
ratio: 9.0 / 16.0,
|
||||
label: '9:16',
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
aspectRatio = 9.0 / 16.0;
|
||||
cropController.aspectRatio = 9.0 / 16.0;
|
||||
});
|
||||
},
|
||||
),
|
||||
_AspectRatioButton(
|
||||
cropController: cropController,
|
||||
currentAspectRatio: aspectRatio,
|
||||
ratio: 2.0 / 3.0,
|
||||
label: '2:3',
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
aspectRatio = 2.0 / 3.0;
|
||||
cropController.aspectRatio = 2.0 / 3.0;
|
||||
});
|
||||
},
|
||||
),
|
||||
_AspectRatioButton(
|
||||
cropController: cropController,
|
||||
currentAspectRatio: aspectRatio,
|
||||
ratio: 5.0 / 7.0,
|
||||
label: '5:7',
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
aspectRatio = 5.0 / 7.0;
|
||||
cropController.aspectRatio = 5.0 / 7.0;
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
SegmentedButton(
|
||||
segments: [
|
||||
const ButtonSegment<String>(
|
||||
value: 'transform',
|
||||
label: Text('Transform'),
|
||||
icon: Icon(Icons.transform),
|
||||
),
|
||||
const ButtonSegment<String>(
|
||||
value: 'filters',
|
||||
label: Text('Filters'),
|
||||
icon: Icon(Icons.color_lens),
|
||||
),
|
||||
],
|
||||
selected: {selectedSegment},
|
||||
onSelectionChanged: (value) => setState(() {
|
||||
selectedSegment = value.first;
|
||||
}),
|
||||
showSelectedIcon: false,
|
||||
),
|
||||
const Spacer(),
|
||||
ImmichTextButton(
|
||||
labelText: "Reset",
|
||||
onPressed: _resetEdits,
|
||||
variant: ImmichVariant.filled,
|
||||
expanded: false,
|
||||
disabled: !hasEdits,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -432,14 +386,12 @@ class _DriftEditImagePageState extends ConsumerState<DriftEditImagePage> with Ti
|
||||
}
|
||||
|
||||
class _AspectRatioButton extends StatelessWidget {
|
||||
final CropController cropController;
|
||||
final double? currentAspectRatio;
|
||||
final double? ratio;
|
||||
final String label;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
const _AspectRatioButton({
|
||||
required this.cropController,
|
||||
required this.currentAspectRatio,
|
||||
required this.ratio,
|
||||
required this.label,
|
||||
@@ -474,3 +426,194 @@ class _AspectRatioButton extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AspectRatioSelector extends StatelessWidget {
|
||||
final double? currentAspectRatio;
|
||||
final void Function(double?) onAspectRatioSelected;
|
||||
|
||||
const _AspectRatioSelector({required this.currentAspectRatio, required this.onAspectRatioSelected});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final aspectRatios = <String, double?>{
|
||||
'Free': null,
|
||||
'1:1': 1.0,
|
||||
'16:9': 16 / 9,
|
||||
'3:2': 3 / 2,
|
||||
'7:5': 7 / 5,
|
||||
'9:16': 9 / 16,
|
||||
'2:3': 2 / 3,
|
||||
'5:7': 5 / 7,
|
||||
};
|
||||
|
||||
return SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: aspectRatios.entries.map((entry) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
child: _AspectRatioButton(
|
||||
currentAspectRatio: currentAspectRatio,
|
||||
ratio: entry.value,
|
||||
label: entry.key,
|
||||
onPressed: () => onAspectRatioSelected(entry.value),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TransformControls extends StatelessWidget {
|
||||
final VoidCallback onRotateLeft;
|
||||
final VoidCallback onRotateRight;
|
||||
final VoidCallback onFlipHorizontal;
|
||||
final VoidCallback onFlipVertical;
|
||||
final void Function(double?) onAspectRatioSelected;
|
||||
final double? aspectRatio;
|
||||
|
||||
const _TransformControls({
|
||||
required this.onRotateLeft,
|
||||
required this.onRotateRight,
|
||||
required this.onFlipHorizontal,
|
||||
required this.onFlipVertical,
|
||||
required this.onAspectRatioSelected,
|
||||
required this.aspectRatio,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 20, right: 20, top: 20, bottom: 10),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
ImmichIconButton(
|
||||
icon: Icons.rotate_left,
|
||||
variant: ImmichVariant.ghost,
|
||||
color: ImmichColor.secondary,
|
||||
onPressed: onRotateLeft,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ImmichIconButton(
|
||||
icon: Icons.rotate_right,
|
||||
variant: ImmichVariant.ghost,
|
||||
color: ImmichColor.secondary,
|
||||
onPressed: onRotateRight,
|
||||
),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
ImmichIconButton(
|
||||
icon: Icons.flip,
|
||||
variant: ImmichVariant.ghost,
|
||||
color: ImmichColor.secondary,
|
||||
onPressed: onFlipHorizontal,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Transform.rotate(
|
||||
angle: pi / 2,
|
||||
child: ImmichIconButton(
|
||||
icon: Icons.flip,
|
||||
variant: ImmichVariant.ghost,
|
||||
color: ImmichColor.secondary,
|
||||
onPressed: onFlipVertical,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
_AspectRatioSelector(currentAspectRatio: aspectRatio, onAspectRatioSelected: onAspectRatioSelected),
|
||||
const SizedBox(height: 32),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FilterControls extends StatelessWidget {
|
||||
final ColorFilter? filter;
|
||||
final Image previewImage;
|
||||
final void Function(ColorFilter?) onApplyFilter;
|
||||
|
||||
const _FilterControls({required this.filter, required this.previewImage, required this.onApplyFilter});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 24),
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: filters.mapIndexed((i, filter) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
child: _FilterButton(
|
||||
image: previewImage,
|
||||
label: filterNames[i],
|
||||
filter: filter,
|
||||
isSelected: filter == filters[i],
|
||||
onTap: () => onApplyFilter(filter),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FilterButton extends StatelessWidget {
|
||||
final Image image;
|
||||
final String label;
|
||||
final ColorFilter filter;
|
||||
final bool isSelected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _FilterButton({
|
||||
required this.image,
|
||||
required this.label,
|
||||
required this.filter,
|
||||
required this.isSelected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(10)),
|
||||
border: isSelected ? Border.all(color: context.primaryColor, width: 3) : null,
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(10)),
|
||||
child: ColorFiltered(
|
||||
colorFilter: filter,
|
||||
child: Image(image: image.image, fit: BoxFit.cover),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(label, style: context.themeData.textTheme.bodyMedium),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/domain/models/asset_edit.model.dart';
|
||||
import 'package:immich_mobile/utils/matrix.utils.dart';
|
||||
import 'package:openapi/api.dart' hide AssetEditAction;
|
||||
@@ -73,3 +75,50 @@ AffineMatrix buildAffineFromEdits(List<AssetEdit> edits) {
|
||||
|
||||
return (rotation, flipX, flipY);
|
||||
}
|
||||
|
||||
class MatrixAdjustmentPainter extends CustomPainter {
|
||||
final ui.Image image;
|
||||
final ColorFilter? filter;
|
||||
|
||||
const MatrixAdjustmentPainter({required this.image, this.filter});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint = Paint()..colorFilter = filter;
|
||||
|
||||
final srcRect = Rect.fromLTWH(0, 0, image.width.toDouble(), image.height.toDouble());
|
||||
final dstRect = Rect.fromLTWH(0, 0, size.width, size.height);
|
||||
|
||||
canvas.drawImageRect(image, srcRect, dstRect, paint);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant MatrixAdjustmentPainter oldDelegate) {
|
||||
return oldDelegate.image != image || oldDelegate.filter != filter;
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper to resolve an ImageProvider to a ui.Image
|
||||
Future<ui.Image> resolveImage(ImageProvider provider) {
|
||||
final completer = Completer<ui.Image>();
|
||||
final stream = provider.resolve(const ImageConfiguration());
|
||||
|
||||
late final ImageStreamListener listener;
|
||||
listener = ImageStreamListener(
|
||||
(ImageInfo info, bool sync) {
|
||||
if (!completer.isCompleted) {
|
||||
completer.complete(info.image);
|
||||
}
|
||||
stream.removeListener(listener);
|
||||
},
|
||||
onError: (error, stackTrace) {
|
||||
if (!completer.isCompleted) {
|
||||
completer.completeError(error, stackTrace);
|
||||
}
|
||||
stream.removeListener(listener);
|
||||
},
|
||||
);
|
||||
|
||||
stream.addListener(listener);
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user