mirror of
https://github.com/flutter/holobooth.git
synced 2025-08-06 14:50:05 +08:00
feat: download video feature (#362)
* feat: adding download video feature * final adjustments and fixes * fix tests * fix tests * pr suggestions and UI improvements * PR suggestions * pr sggestions
This commit is contained in:
22
.github/workflows/download_repository.yaml
vendored
Normal file
22
.github/workflows/download_repository.yaml
vendored
Normal file
@ -0,0 +1,22 @@
|
||||
name: download_repository
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "packages/download_repository/**"
|
||||
- ".github/workflows/download_repository.yaml"
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
build:
|
||||
uses: VeryGoodOpenSource/very_good_workflows/.github/workflows/flutter_package.yml@v1
|
||||
with:
|
||||
flutter_channel: "beta"
|
||||
flutter_version: 3.6.0-0.1.pre
|
||||
coverage_excludes: "**/*.gen.dart"
|
||||
working_directory: packages/download_repository
|
@ -50,7 +50,7 @@ describe('Download API', () => {
|
||||
|
||||
test('Invalid file extension returns 400', async () => {
|
||||
const req = Object.assign({}, baseReq, {
|
||||
path: 'wrong.gif',
|
||||
path: 'wrong.avi',
|
||||
});
|
||||
|
||||
const res = {
|
||||
|
@ -4,7 +4,7 @@ import * as path from 'path';
|
||||
|
||||
import { UPLOAD_PATH, ALLOWED_HOSTS } from '../config';
|
||||
|
||||
const VALID_VIDEO_EXT = [ '.mp4' ];
|
||||
const VALID_VIDEO_EXT = [ '.mp4', '.gif' ];
|
||||
|
||||
/**
|
||||
* Get the files and writes it on the response.
|
||||
|
@ -1,6 +1,7 @@
|
||||
import 'package:authentication_repository/authentication_repository.dart';
|
||||
import 'package:avatar_detector_repository/avatar_detector_repository.dart';
|
||||
import 'package:convert_repository/convert_repository.dart';
|
||||
import 'package:download_repository/download_repository.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:holobooth/l10n/l10n.dart';
|
||||
@ -13,11 +14,13 @@ class App extends StatelessWidget {
|
||||
required this.authenticationRepository,
|
||||
required this.avatarDetectorRepository,
|
||||
required this.convertRepository,
|
||||
required this.downloadRepository,
|
||||
});
|
||||
|
||||
final AuthenticationRepository authenticationRepository;
|
||||
final AvatarDetectorRepository avatarDetectorRepository;
|
||||
final ConvertRepository convertRepository;
|
||||
final DownloadRepository downloadRepository;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@ -26,6 +29,7 @@ class App extends StatelessWidget {
|
||||
RepositoryProvider.value(value: authenticationRepository),
|
||||
RepositoryProvider.value(value: avatarDetectorRepository),
|
||||
RepositoryProvider.value(value: convertRepository),
|
||||
RepositoryProvider.value(value: downloadRepository),
|
||||
],
|
||||
child: AnimatedFadeIn(
|
||||
child: ResponsiveLayoutBuilder(
|
||||
|
@ -5,6 +5,7 @@ import 'package:authentication_repository/authentication_repository.dart';
|
||||
import 'package:avatar_detector_repository/avatar_detector_repository.dart';
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:convert_repository/convert_repository.dart';
|
||||
import 'package:download_repository/download_repository.dart';
|
||||
import 'package:firebase_auth/firebase_auth.dart';
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
@ -45,6 +46,8 @@ Future<void> bootstrap({
|
||||
assetBucketUrl: assetBucketUrl,
|
||||
);
|
||||
|
||||
final downloadRepository = DownloadRepository();
|
||||
|
||||
unawaited(
|
||||
Future.wait([
|
||||
Flame.images.load('holobooth_avatar.png'),
|
||||
@ -57,6 +60,7 @@ Future<void> bootstrap({
|
||||
authenticationRepository: authenticationRepository,
|
||||
avatarDetectorRepository: avatarDetectorRepository,
|
||||
convertRepository: convertRepository,
|
||||
downloadRepository: downloadRepository,
|
||||
),
|
||||
),
|
||||
(error, stackTrace) {
|
||||
|
35
lib/share/bloc/download_bloc.dart
Normal file
35
lib/share/bloc/download_bloc.dart
Normal file
@ -0,0 +1,35 @@
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:download_repository/download_repository.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
part 'download_event.dart';
|
||||
part 'download_state.dart';
|
||||
|
||||
class DownloadBloc extends Bloc<DownloadEvent, DownloadState> {
|
||||
DownloadBloc({
|
||||
required String videoPath,
|
||||
required DownloadRepository downloadRepository,
|
||||
}) : _downloadRepository = downloadRepository,
|
||||
super(DownloadState.initial(videoPath: videoPath)) {
|
||||
on<DownloadRequested>(_onDownloadEvent);
|
||||
}
|
||||
|
||||
final DownloadRepository _downloadRepository;
|
||||
|
||||
Future<void> _onDownloadEvent(
|
||||
DownloadRequested event,
|
||||
Emitter<DownloadState> emit,
|
||||
) async {
|
||||
emit(state.copyWith(status: DownloadStatus.fetching));
|
||||
|
||||
final videoHash = state.videoPath.split('/').last.split('.').first;
|
||||
|
||||
final fileName = '$videoHash.${event.extension}';
|
||||
final mimeType = event.extension == 'mp4' ? 'video/mp4' : 'image/gif';
|
||||
await _downloadRepository.downloadFile(
|
||||
fileName,
|
||||
mimeType,
|
||||
);
|
||||
emit(state.copyWith(status: DownloadStatus.completed));
|
||||
}
|
||||
}
|
18
lib/share/bloc/download_event.dart
Normal file
18
lib/share/bloc/download_event.dart
Normal file
@ -0,0 +1,18 @@
|
||||
part of 'download_bloc.dart';
|
||||
|
||||
abstract class DownloadEvent extends Equatable {
|
||||
const DownloadEvent();
|
||||
}
|
||||
|
||||
class DownloadRequested extends DownloadEvent {
|
||||
const DownloadRequested(this.extension);
|
||||
|
||||
const DownloadRequested.video() : this('mp4');
|
||||
|
||||
const DownloadRequested.gif() : this('gif');
|
||||
|
||||
final String extension;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [extension];
|
||||
}
|
38
lib/share/bloc/download_state.dart
Normal file
38
lib/share/bloc/download_state.dart
Normal file
@ -0,0 +1,38 @@
|
||||
part of 'download_bloc.dart';
|
||||
|
||||
enum DownloadStatus {
|
||||
idle,
|
||||
fetching,
|
||||
completed,
|
||||
}
|
||||
|
||||
class DownloadState extends Equatable {
|
||||
const DownloadState({
|
||||
required this.videoPath,
|
||||
required this.status,
|
||||
});
|
||||
|
||||
const DownloadState.initial({
|
||||
required String videoPath,
|
||||
}) : this(
|
||||
videoPath: videoPath,
|
||||
status: DownloadStatus.idle,
|
||||
);
|
||||
|
||||
final String videoPath;
|
||||
|
||||
final DownloadStatus status;
|
||||
|
||||
DownloadState copyWith({
|
||||
String? videoPath,
|
||||
DownloadStatus? status,
|
||||
}) {
|
||||
return DownloadState(
|
||||
videoPath: videoPath ?? this.videoPath,
|
||||
status: status ?? this.status,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [videoPath, status];
|
||||
}
|
@ -1,2 +1,3 @@
|
||||
export 'bloc/download_bloc.dart';
|
||||
export 'view/view.dart';
|
||||
export 'widgets/widgets.dart';
|
||||
|
@ -1,5 +1,6 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:download_repository/download_repository.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:holobooth/convert/convert.dart';
|
||||
@ -37,6 +38,12 @@ class SharePage extends StatelessWidget {
|
||||
return MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider.value(value: convertBloc),
|
||||
BlocProvider(
|
||||
create: (_) => DownloadBloc(
|
||||
videoPath: convertBloc.state.videoPath,
|
||||
downloadRepository: context.read<DownloadRepository>(),
|
||||
),
|
||||
),
|
||||
],
|
||||
child: const ShareView(),
|
||||
);
|
||||
|
@ -1,5 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:holobooth/l10n/l10n.dart';
|
||||
import 'package:holobooth/share/share.dart';
|
||||
import 'package:holobooth_ui/holobooth_ui.dart';
|
||||
|
||||
class DownloadButton extends StatefulWidget {
|
||||
@ -15,30 +17,53 @@ class _DownloadButtonState extends State<DownloadButton> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
final state = context.watch<DownloadBloc>().state;
|
||||
final isLoading = state.status == DownloadStatus.fetching;
|
||||
|
||||
return CompositedTransformTarget(
|
||||
link: layerLink,
|
||||
child: GradientOutlinedButton(
|
||||
icon: const Icon(
|
||||
Icons.file_download_rounded,
|
||||
color: HoloBoothColors.white,
|
||||
),
|
||||
icon: isLoading
|
||||
? const SizedBox(
|
||||
width: 25,
|
||||
height: 25,
|
||||
child: CircularProgressIndicator(
|
||||
color: HoloBoothColors.convertLoading,
|
||||
),
|
||||
)
|
||||
: const Icon(
|
||||
Icons.file_download_rounded,
|
||||
color: HoloBoothColors.white,
|
||||
),
|
||||
label: l10n.sharePageDownloadButtonText,
|
||||
onPressed: () {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
barrierColor: HoloBoothColors.transparent,
|
||||
builder: (context) => DownloadOptionDialog(layerLink: layerLink),
|
||||
);
|
||||
},
|
||||
onPressed: isLoading
|
||||
? null
|
||||
: () {
|
||||
final downloadBloc = context.read<DownloadBloc>();
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
barrierColor: HoloBoothColors.transparent,
|
||||
builder: (context) => BlocProvider.value(
|
||||
value: downloadBloc,
|
||||
child: DownloadOptionDialog(
|
||||
layerLink: layerLink,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DownloadOptionDialog extends StatelessWidget {
|
||||
const DownloadOptionDialog({super.key, required this.layerLink});
|
||||
const DownloadOptionDialog({
|
||||
super.key,
|
||||
required this.layerLink,
|
||||
});
|
||||
|
||||
final LayerLink layerLink;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return CompositedTransformFollower(
|
||||
@ -74,6 +99,7 @@ class DownloadAsAGifButton extends StatelessWidget {
|
||||
),
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
context.read<DownloadBloc>().add(const DownloadRequested.gif());
|
||||
},
|
||||
icon: ShaderMask(
|
||||
shaderCallback: (bounds) {
|
||||
@ -103,6 +129,7 @@ class DownloadAsAVideoButton extends StatelessWidget {
|
||||
),
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
context.read<DownloadBloc>().add(const DownloadRequested.video());
|
||||
},
|
||||
icon: ShaderMask(
|
||||
shaderCallback: (bounds) {
|
||||
|
43
packages/download_repository/.gitignore
vendored
Normal file
43
packages/download_repository/.gitignore
vendored
Normal file
@ -0,0 +1,43 @@
|
||||
# Miscellaneous
|
||||
*.class
|
||||
*.log
|
||||
*.pyc
|
||||
*.swp
|
||||
.DS_Store
|
||||
.atom/
|
||||
.buildlog/
|
||||
.history
|
||||
.svn/
|
||||
|
||||
# IntelliJ related
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
.idea/
|
||||
|
||||
# VSCode related
|
||||
.vscode/
|
||||
|
||||
# Flutter/Dart/Pub related
|
||||
**/doc/api/
|
||||
**/ios/Flutter/.last_build_id
|
||||
.dart_tool/
|
||||
.flutter-plugins
|
||||
.flutter-plugins-dependencies
|
||||
.packages
|
||||
.pub-cache/
|
||||
.pub/
|
||||
/build/
|
||||
pubspec.lock
|
||||
|
||||
# Web related
|
||||
lib/generated_plugin_registrant.dart
|
||||
|
||||
# Symbolication related
|
||||
app.*.symbols
|
||||
|
||||
# Obfuscation related
|
||||
app.*.map.json
|
||||
|
||||
# Test related
|
||||
coverage
|
1
packages/download_repository/analysis_options.yaml
Normal file
1
packages/download_repository/analysis_options.yaml
Normal file
@ -0,0 +1 @@
|
||||
include: package:very_good_analysis/analysis_options.3.1.0.yaml
|
20
packages/download_repository/coverage_badge.svg
Normal file
20
packages/download_repository/coverage_badge.svg
Normal file
@ -0,0 +1,20 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="102" height="20">
|
||||
<linearGradient id="b" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1" />
|
||||
<stop offset="1" stop-opacity=".1" />
|
||||
</linearGradient>
|
||||
<clipPath id="a">
|
||||
<rect width="102" height="20" rx="3" fill="#fff" />
|
||||
</clipPath>
|
||||
<g clip-path="url(#a)">
|
||||
<path fill="#555" d="M0 0h59v20H0z" />
|
||||
<path fill="#44cc11" d="M59 0h43v20H59z" />
|
||||
<path fill="url(#b)" d="M0 0h102v20H0z" />
|
||||
</g>
|
||||
<g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="110">
|
||||
<text x="305" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="490">coverage</text>
|
||||
<text x="305" y="140" transform="scale(.1)" textLength="490">coverage</text>
|
||||
<text x="795" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="330">100%</text>
|
||||
<text x="795" y="140" transform="scale(.1)" textLength="330">100%</text>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 1.1 KiB |
@ -0,0 +1,4 @@
|
||||
/// Repository responsible for fetching and downloading a file.
|
||||
library download_repository;
|
||||
|
||||
export 'src/download_repository.dart';
|
@ -0,0 +1,36 @@
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:camera/camera.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
/// {@template download_repository}
|
||||
/// Repository responsible for fetching and downloading a file.
|
||||
/// {@endtemplate}
|
||||
class DownloadRepository {
|
||||
/// {@macro download_repository}
|
||||
DownloadRepository({
|
||||
Future<http.Response> Function(Uri)? get,
|
||||
XFile Function(Uint8List, {String? mimeType})? parseBytes,
|
||||
}) {
|
||||
_get = get ?? http.get;
|
||||
_parseBytes = parseBytes ?? XFile.fromData;
|
||||
}
|
||||
|
||||
late final Future<http.Response> Function(Uri) _get;
|
||||
late final XFile Function(Uint8List, {String? mimeType}) _parseBytes;
|
||||
|
||||
/// Fetches the given [fileName] and save it locally.
|
||||
Future<void> downloadFile(String fileName, String mimeType) async {
|
||||
final uri = Uri.parse('/download/$fileName');
|
||||
final response = await _get(
|
||||
uri,
|
||||
);
|
||||
final bytes = response.bodyBytes;
|
||||
final file = _parseBytes(
|
||||
bytes,
|
||||
mimeType: mimeType,
|
||||
);
|
||||
await file.saveTo(fileName);
|
||||
}
|
||||
}
|
20
packages/download_repository/pubspec.yaml
Normal file
20
packages/download_repository/pubspec.yaml
Normal file
@ -0,0 +1,20 @@
|
||||
name: download_repository
|
||||
description: Repository responsible for fetching and downloading a file.
|
||||
version: 0.1.0+1
|
||||
publish_to: none
|
||||
|
||||
environment:
|
||||
sdk: ">=2.18.0 <3.0.0"
|
||||
flutter: 3.3.7
|
||||
|
||||
dependencies:
|
||||
camera: ^0.10.0+3
|
||||
flutter:
|
||||
sdk: flutter
|
||||
http: ^0.13.5
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
mocktail: ^0.3.0
|
||||
very_good_analysis: ^3.1.0
|
@ -0,0 +1,38 @@
|
||||
// ignore_for_file: prefer_const_constructors
|
||||
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:camera/camera.dart';
|
||||
import 'package:download_repository/download_repository.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
class MockResponse extends Mock implements Response {}
|
||||
|
||||
class MockXFile extends Mock implements XFile {}
|
||||
|
||||
void main() {
|
||||
group('DownloadRepository', () {
|
||||
test('can be instantiated', () {
|
||||
expect(DownloadRepository(), isNotNull);
|
||||
});
|
||||
|
||||
test('fetches and save the file', () async {
|
||||
final response = MockResponse();
|
||||
final file = MockXFile();
|
||||
|
||||
when(() => response.bodyBytes).thenReturn(Uint8List(1));
|
||||
when(() => file.saveTo(any())).thenAnswer((_) async {});
|
||||
|
||||
final repo = DownloadRepository(
|
||||
get: (_) async => response,
|
||||
parseBytes: (_, {String? mimeType}) => file,
|
||||
);
|
||||
|
||||
await repo.downloadFile('file', 'video/mp4');
|
||||
|
||||
verify(() => file.saveTo('file')).called(1);
|
||||
});
|
||||
});
|
||||
}
|
@ -247,6 +247,13 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.4.1"
|
||||
download_repository:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "packages/download_repository"
|
||||
relative: true
|
||||
source: path
|
||||
version: "0.1.0+1"
|
||||
equatable:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
@ -21,6 +21,8 @@ dependencies:
|
||||
convert_repository:
|
||||
path: packages/convert_repository
|
||||
cross_file: ^0.3.3+2
|
||||
download_repository:
|
||||
path: packages/download_repository
|
||||
equatable: ^2.0.5
|
||||
face_geometry:
|
||||
path: packages/face_geometry
|
||||
|
@ -1,6 +1,7 @@
|
||||
import 'package:authentication_repository/authentication_repository.dart';
|
||||
import 'package:avatar_detector_repository/avatar_detector_repository.dart';
|
||||
import 'package:convert_repository/convert_repository.dart';
|
||||
import 'package:download_repository/download_repository.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:holobooth/app/app.dart';
|
||||
@ -15,6 +16,8 @@ class _MockAuthenticationRepository extends Mock
|
||||
|
||||
class _MockConvertRepository extends Mock implements ConvertRepository {}
|
||||
|
||||
class _MockDownloadRepository extends Mock implements DownloadRepository {}
|
||||
|
||||
class _MockAvatarDetectorRepository extends Mock
|
||||
implements AvatarDetectorRepository {
|
||||
_MockAvatarDetectorRepository() {
|
||||
@ -31,6 +34,7 @@ void main() {
|
||||
authenticationRepository: _MockAuthenticationRepository(),
|
||||
avatarDetectorRepository: _MockAvatarDetectorRepository(),
|
||||
convertRepository: _MockConvertRepository(),
|
||||
downloadRepository: _MockDownloadRepository(),
|
||||
),
|
||||
);
|
||||
final materialApp = tester.widget<MaterialApp>(find.byType(MaterialApp));
|
||||
@ -47,6 +51,7 @@ void main() {
|
||||
authenticationRepository: _MockAuthenticationRepository(),
|
||||
avatarDetectorRepository: _MockAvatarDetectorRepository(),
|
||||
convertRepository: _MockConvertRepository(),
|
||||
downloadRepository: _MockDownloadRepository(),
|
||||
),
|
||||
);
|
||||
final materialApp = tester.widget<MaterialApp>(find.byType(MaterialApp));
|
||||
@ -62,6 +67,7 @@ void main() {
|
||||
authenticationRepository: _MockAuthenticationRepository(),
|
||||
avatarDetectorRepository: _MockAvatarDetectorRepository(),
|
||||
convertRepository: _MockConvertRepository(),
|
||||
downloadRepository: _MockDownloadRepository(),
|
||||
),
|
||||
);
|
||||
expect(find.byType(LandingPage), findsOneWidget);
|
||||
|
@ -45,7 +45,9 @@ void main() {
|
||||
),
|
||||
);
|
||||
},
|
||||
build: () => ConvertBloc(convertRepository: convertRepository),
|
||||
build: () => ConvertBloc(
|
||||
convertRepository: convertRepository,
|
||||
),
|
||||
act: (bloc) => bloc.add(GenerateVideoRequested(frames)),
|
||||
expect: () => [
|
||||
ConvertState(),
|
||||
@ -64,7 +66,9 @@ void main() {
|
||||
when(() => convertRepository.generateVideo(any()))
|
||||
.thenThrow(Exception());
|
||||
},
|
||||
build: () => ConvertBloc(convertRepository: convertRepository),
|
||||
build: () => ConvertBloc(
|
||||
convertRepository: convertRepository,
|
||||
),
|
||||
act: (bloc) => bloc.add(GenerateVideoRequested(frames)),
|
||||
expect: () => [
|
||||
ConvertState(),
|
||||
|
@ -2,6 +2,7 @@ import 'dart:typed_data';
|
||||
|
||||
import 'package:avatar_detector_repository/avatar_detector_repository.dart';
|
||||
import 'package:convert_repository/convert_repository.dart';
|
||||
import 'package:download_repository/download_repository.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
@ -13,6 +14,8 @@ import 'package:tensorflow_models/tensorflow_models.dart' as tf;
|
||||
|
||||
class _MockConvertRepository extends Mock implements ConvertRepository {}
|
||||
|
||||
class _MockDownloadRepository extends Mock implements DownloadRepository {}
|
||||
|
||||
class _MockAvatarDetectorRepository extends Mock
|
||||
implements AvatarDetectorRepository {
|
||||
_MockAvatarDetectorRepository() {
|
||||
@ -31,6 +34,7 @@ extension PumpApp on WidgetTester {
|
||||
Widget widget, {
|
||||
AvatarDetectorRepository? avatarDetectorRepository,
|
||||
ConvertRepository? convertRepository,
|
||||
DownloadRepository? downloadRepository,
|
||||
}) async {
|
||||
return mockNetworkImages(() async {
|
||||
return pumpWidget(
|
||||
@ -43,6 +47,9 @@ extension PumpApp on WidgetTester {
|
||||
RepositoryProvider.value(
|
||||
value: convertRepository ?? _MockConvertRepository(),
|
||||
),
|
||||
RepositoryProvider.value(
|
||||
value: downloadRepository ?? _MockDownloadRepository(),
|
||||
),
|
||||
],
|
||||
child: MaterialApp(
|
||||
localizationsDelegates: const [
|
||||
|
@ -42,6 +42,9 @@ class _MockImage extends Mock implements ui.Image {}
|
||||
|
||||
class _MockAudioPlayer extends Mock implements AudioPlayer {}
|
||||
|
||||
class _MockConvertBloc extends MockBloc<ConvertEvent, ConvertState>
|
||||
implements ConvertBloc {}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
@ -144,6 +147,7 @@ void main() {
|
||||
|
||||
group('PhotoBoothView', () {
|
||||
late PhotoBoothBloc photoBoothBloc;
|
||||
late ConvertBloc convertBloc;
|
||||
late InExperienceSelectionBloc inExperienceSelectionBloc;
|
||||
late AvatarDetectorBloc avatarDetectorBloc;
|
||||
|
||||
@ -161,6 +165,9 @@ void main() {
|
||||
when(() => avatarDetectorBloc.state).thenReturn(
|
||||
AvatarDetectorState(status: AvatarDetectorStatus.loaded),
|
||||
);
|
||||
|
||||
convertBloc = _MockConvertBloc();
|
||||
when(() => convertBloc.state).thenReturn(const ConvertState());
|
||||
});
|
||||
|
||||
testWidgets('plays audio', (WidgetTester tester) async {
|
||||
@ -169,6 +176,7 @@ void main() {
|
||||
photoBoothBloc: photoBoothBloc,
|
||||
inExperienceSelectionBloc: inExperienceSelectionBloc,
|
||||
avatarDetectorBloc: avatarDetectorBloc,
|
||||
convertBloc: convertBloc,
|
||||
);
|
||||
|
||||
await tester.pump();
|
||||
@ -192,6 +200,7 @@ void main() {
|
||||
photoBoothBloc: photoBoothBloc,
|
||||
inExperienceSelectionBloc: inExperienceSelectionBloc,
|
||||
avatarDetectorBloc: avatarDetectorBloc,
|
||||
convertBloc: convertBloc,
|
||||
);
|
||||
|
||||
/// Wait for the player to complete
|
||||
@ -209,6 +218,7 @@ extension on WidgetTester {
|
||||
required PhotoBoothBloc photoBoothBloc,
|
||||
required InExperienceSelectionBloc inExperienceSelectionBloc,
|
||||
required AvatarDetectorBloc avatarDetectorBloc,
|
||||
required ConvertBloc convertBloc,
|
||||
}) =>
|
||||
pumpApp(
|
||||
MultiBlocProvider(
|
||||
@ -216,6 +226,7 @@ extension on WidgetTester {
|
||||
BlocProvider.value(value: photoBoothBloc),
|
||||
BlocProvider.value(value: inExperienceSelectionBloc),
|
||||
BlocProvider.value(value: avatarDetectorBloc),
|
||||
BlocProvider.value(value: convertBloc),
|
||||
],
|
||||
child: subject,
|
||||
),
|
||||
|
73
test/share/bloc/download_bloc_test.dart
Normal file
73
test/share/bloc/download_bloc_test.dart
Normal file
@ -0,0 +1,73 @@
|
||||
import 'package:bloc_test/bloc_test.dart';
|
||||
import 'package:download_repository/download_repository.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:holobooth/share/share.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
class _MockDownloadRepository extends Mock implements DownloadRepository {}
|
||||
|
||||
void main() {
|
||||
late DownloadRepository downloadRepository;
|
||||
|
||||
group('DownloadBloc', () {
|
||||
setUp(() {
|
||||
downloadRepository = _MockDownloadRepository();
|
||||
});
|
||||
|
||||
group('download', () {
|
||||
blocTest<DownloadBloc, DownloadState>(
|
||||
'downloads the current converted file',
|
||||
setUp: () {
|
||||
when(() => downloadRepository.downloadFile(any(), any()))
|
||||
.thenAnswer((_) async {});
|
||||
},
|
||||
build: () => DownloadBloc(
|
||||
downloadRepository: downloadRepository,
|
||||
videoPath: 'https://storage/1234.mp4',
|
||||
),
|
||||
act: (bloc) => bloc.add(DownloadRequested('mp4')),
|
||||
verify: (_) {
|
||||
verify(
|
||||
() => downloadRepository.downloadFile(any(), any()),
|
||||
).called(1);
|
||||
},
|
||||
);
|
||||
|
||||
blocTest<DownloadBloc, DownloadState>(
|
||||
'correctly downloads a mp4',
|
||||
setUp: () {
|
||||
when(() => downloadRepository.downloadFile(any(), any()))
|
||||
.thenAnswer((_) async {});
|
||||
},
|
||||
build: () => DownloadBloc(
|
||||
downloadRepository: downloadRepository,
|
||||
videoPath: 'https://storage/1234.mp4',
|
||||
),
|
||||
act: (bloc) => bloc.add(DownloadRequested('mp4')),
|
||||
verify: (_) {
|
||||
verify(
|
||||
() => downloadRepository.downloadFile('1234.mp4', 'video/mp4'),
|
||||
).called(1);
|
||||
},
|
||||
);
|
||||
|
||||
blocTest<DownloadBloc, DownloadState>(
|
||||
'correctly downloads a gif',
|
||||
setUp: () {
|
||||
when(() => downloadRepository.downloadFile(any(), any()))
|
||||
.thenAnswer((_) async {});
|
||||
},
|
||||
build: () => DownloadBloc(
|
||||
downloadRepository: downloadRepository,
|
||||
videoPath: 'https://storage/1234.mp4',
|
||||
),
|
||||
act: (bloc) => bloc.add(DownloadRequested('gif')),
|
||||
verify: (_) {
|
||||
verify(
|
||||
() => downloadRepository.downloadFile('1234.gif', 'image/gif'),
|
||||
).called(1);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
17
test/share/bloc/download_event_test.dart
Normal file
17
test/share/bloc/download_event_test.dart
Normal file
@ -0,0 +1,17 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:holobooth/share/share.dart';
|
||||
|
||||
void main() {
|
||||
group('DownloadRequested', () {
|
||||
test('can be instantiated', () {
|
||||
expect(DownloadRequested(''), isNotNull);
|
||||
expect(DownloadRequested.video().extension, equals('mp4'));
|
||||
expect(DownloadRequested.gif().extension, equals('gif'));
|
||||
});
|
||||
|
||||
test('supports equality', () {
|
||||
expect(DownloadRequested(''), equals(DownloadRequested('')));
|
||||
expect(DownloadRequested(''), isNot(equals(DownloadRequested('a'))));
|
||||
});
|
||||
});
|
||||
}
|
89
test/share/bloc/download_state_test.dart
Normal file
89
test/share/bloc/download_state_test.dart
Normal file
@ -0,0 +1,89 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:holobooth/share/share.dart';
|
||||
|
||||
void main() {
|
||||
group('DownloadState', () {
|
||||
test('can be instantiated', () {
|
||||
expect(
|
||||
DownloadState(
|
||||
videoPath: '',
|
||||
status: DownloadStatus.idle,
|
||||
),
|
||||
isNotNull,
|
||||
);
|
||||
});
|
||||
|
||||
test('supports equality', () {
|
||||
expect(
|
||||
DownloadState(
|
||||
videoPath: '',
|
||||
status: DownloadStatus.idle,
|
||||
),
|
||||
equals(
|
||||
DownloadState(
|
||||
videoPath: '',
|
||||
status: DownloadStatus.idle,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
DownloadState(
|
||||
videoPath: '',
|
||||
status: DownloadStatus.idle,
|
||||
),
|
||||
isNot(
|
||||
equals(
|
||||
DownloadState(
|
||||
videoPath: 'a',
|
||||
status: DownloadStatus.idle,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
DownloadState(
|
||||
videoPath: '',
|
||||
status: DownloadStatus.idle,
|
||||
),
|
||||
isNot(
|
||||
equals(
|
||||
DownloadState(
|
||||
videoPath: '',
|
||||
status: DownloadStatus.fetching,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('copyWith returns a new instance with the updated values', () {
|
||||
expect(
|
||||
DownloadState(
|
||||
videoPath: '',
|
||||
status: DownloadStatus.idle,
|
||||
).copyWith(videoPath: 'a'),
|
||||
equals(
|
||||
DownloadState(
|
||||
videoPath: 'a',
|
||||
status: DownloadStatus.idle,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
DownloadState(
|
||||
videoPath: '',
|
||||
status: DownloadStatus.idle,
|
||||
).copyWith(status: DownloadStatus.fetching),
|
||||
equals(
|
||||
DownloadState(
|
||||
videoPath: '',
|
||||
status: DownloadStatus.fetching,
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
@ -16,6 +16,9 @@ import '../../helpers/helpers.dart';
|
||||
class _MockConvertBloc extends MockBloc<ConvertEvent, ConvertState>
|
||||
implements ConvertBloc {}
|
||||
|
||||
class _MockDownloadBloc extends MockBloc<DownloadEvent, DownloadState>
|
||||
implements DownloadBloc {}
|
||||
|
||||
class _MockUrlLauncher extends Mock
|
||||
with MockPlatformInterfaceMixin
|
||||
implements UrlLauncherPlatform {}
|
||||
@ -25,11 +28,17 @@ void main() {
|
||||
late Uint8List firstFrame;
|
||||
|
||||
late ConvertBloc convertBloc;
|
||||
late DownloadBloc downloadBloc;
|
||||
|
||||
setUp(() async {
|
||||
final image = await createTestImage(height: 10, width: 10);
|
||||
final bytesImage = await image.toByteData(format: ImageByteFormat.png);
|
||||
firstFrame = bytesImage!.buffer.asUint8List();
|
||||
convertBloc = _MockConvertBloc();
|
||||
|
||||
downloadBloc = _MockDownloadBloc();
|
||||
when(() => downloadBloc.state)
|
||||
.thenReturn(const DownloadState.initial(videoPath: ''));
|
||||
});
|
||||
|
||||
test('is routable', () {
|
||||
@ -47,6 +56,7 @@ void main() {
|
||||
group('ShareView', () {
|
||||
late UrlLauncherPlatform mock;
|
||||
late ConvertBloc convertBloc;
|
||||
late DownloadBloc downloadBloc;
|
||||
|
||||
setUp(() {
|
||||
mock = _MockUrlLauncher();
|
||||
@ -58,6 +68,10 @@ void main() {
|
||||
when(
|
||||
() => mock.launchUrl(any(), any()),
|
||||
).thenAnswer((_) async => true);
|
||||
|
||||
downloadBloc = _MockDownloadBloc();
|
||||
when(() => downloadBloc.state)
|
||||
.thenReturn(const DownloadState.initial(videoPath: ''));
|
||||
});
|
||||
|
||||
setUpAll(() {
|
||||
@ -67,7 +81,8 @@ void main() {
|
||||
testWidgets('renders ShareBackground', (tester) async {
|
||||
await tester.pumpSubject(
|
||||
ShareView(),
|
||||
convertBloc,
|
||||
convertBloc: convertBloc,
|
||||
downloadBloc: downloadBloc,
|
||||
);
|
||||
expect(find.byType(ShareBackground), findsOneWidget);
|
||||
});
|
||||
@ -75,7 +90,8 @@ void main() {
|
||||
testWidgets('contains a ShareBody', (tester) async {
|
||||
await tester.pumpSubject(
|
||||
ShareView(),
|
||||
convertBloc,
|
||||
convertBloc: convertBloc,
|
||||
downloadBloc: downloadBloc,
|
||||
);
|
||||
expect(find.byType(ShareBody), findsOneWidget);
|
||||
});
|
||||
@ -83,9 +99,17 @@ void main() {
|
||||
}
|
||||
|
||||
extension on WidgetTester {
|
||||
Future<void> pumpSubject(ShareView subject, ConvertBloc bloc) => pumpApp(
|
||||
BlocProvider.value(
|
||||
value: bloc,
|
||||
Future<void> pumpSubject(
|
||||
ShareView subject, {
|
||||
required ConvertBloc convertBloc,
|
||||
required DownloadBloc downloadBloc,
|
||||
}) =>
|
||||
pumpApp(
|
||||
MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider.value(value: convertBloc),
|
||||
BlocProvider.value(value: downloadBloc),
|
||||
],
|
||||
child: subject,
|
||||
),
|
||||
);
|
||||
|
@ -1,12 +1,58 @@
|
||||
import 'package:bloc_test/bloc_test.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:holobooth/convert/convert.dart';
|
||||
import 'package:holobooth/share/share.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
import '../../helpers/helpers.dart';
|
||||
|
||||
class _MockConvertBloc extends MockBloc<ConvertEvent, ConvertState>
|
||||
implements ConvertBloc {}
|
||||
|
||||
class _MockDownloadBloc extends MockBloc<DownloadEvent, DownloadState>
|
||||
implements DownloadBloc {}
|
||||
|
||||
void main() {
|
||||
group('DownloadButton', () {
|
||||
late ConvertBloc convertBloc;
|
||||
late DownloadBloc downloadBloc;
|
||||
|
||||
setUp(() {
|
||||
convertBloc = _MockConvertBloc();
|
||||
when(() => convertBloc.state).thenReturn(
|
||||
const ConvertState(videoPath: 'https://storage/videoPath.mp4'),
|
||||
);
|
||||
|
||||
downloadBloc = _MockDownloadBloc();
|
||||
when(() => downloadBloc.state).thenReturn(
|
||||
const DownloadState.initial(videoPath: 'https://storage/videoPath.mp4'),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('renders a loading indicator when loading', (tester) async {
|
||||
when(() => downloadBloc.state).thenReturn(
|
||||
const DownloadState(
|
||||
videoPath: 'https://storage/videoPath.mp4',
|
||||
status: DownloadStatus.fetching,
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpSubject(
|
||||
DownloadButton(),
|
||||
convertBloc: convertBloc,
|
||||
downloadBloc: downloadBloc,
|
||||
);
|
||||
expect(find.byType(CircularProgressIndicator), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('opens DownloadOptionDialog on tap', (tester) async {
|
||||
await tester.pumpApp(DownloadButton());
|
||||
await tester.pumpSubject(
|
||||
DownloadButton(),
|
||||
convertBloc: convertBloc,
|
||||
downloadBloc: downloadBloc,
|
||||
);
|
||||
await tester.tap(find.byType(DownloadButton));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(DownloadOptionDialog), findsOneWidget);
|
||||
@ -14,7 +60,11 @@ void main() {
|
||||
|
||||
testWidgets('closes DownloadOptionDialog tapping on DownloadAsAGifButton',
|
||||
(tester) async {
|
||||
await tester.pumpApp(DownloadButton());
|
||||
await tester.pumpSubject(
|
||||
DownloadButton(),
|
||||
convertBloc: convertBloc,
|
||||
downloadBloc: downloadBloc,
|
||||
);
|
||||
await tester.tap(find.byType(DownloadButton));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byType(DownloadAsAGifButton));
|
||||
@ -24,12 +74,64 @@ void main() {
|
||||
|
||||
testWidgets('closes DownloadOptionDialog tapping on DownloadAsAVideoButton',
|
||||
(tester) async {
|
||||
await tester.pumpApp(DownloadButton());
|
||||
await tester.pumpSubject(
|
||||
DownloadButton(),
|
||||
convertBloc: convertBloc,
|
||||
downloadBloc: downloadBloc,
|
||||
);
|
||||
await tester.tap(find.byType(DownloadButton));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byType(DownloadAsAVideoButton));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(DownloadOptionDialog), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('downloads the gif when tapping on DownloadAsAGifButton',
|
||||
(tester) async {
|
||||
await tester.pumpSubject(
|
||||
DownloadButton(),
|
||||
convertBloc: convertBloc,
|
||||
downloadBloc: downloadBloc,
|
||||
);
|
||||
await tester.tap(find.byType(DownloadButton));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byType(DownloadAsAGifButton));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
verify(() => downloadBloc.add(DownloadRequested('gif'))).called(1);
|
||||
});
|
||||
|
||||
testWidgets('downloads the mp4 when tapping on DownloadAsAVideoButton',
|
||||
(tester) async {
|
||||
await tester.pumpSubject(
|
||||
DownloadButton(),
|
||||
convertBloc: convertBloc,
|
||||
downloadBloc: downloadBloc,
|
||||
);
|
||||
await tester.tap(find.byType(DownloadButton));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byType(DownloadAsAVideoButton));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
verify(() => downloadBloc.add(DownloadRequested('mp4'))).called(1);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
extension BuildSubect on WidgetTester {
|
||||
Future<void> pumpSubject(
|
||||
Widget subject, {
|
||||
required ConvertBloc convertBloc,
|
||||
required DownloadBloc downloadBloc,
|
||||
}) async {
|
||||
await pumpApp(
|
||||
MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider.value(value: convertBloc),
|
||||
BlocProvider.value(value: downloadBloc),
|
||||
],
|
||||
child: subject,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -15,9 +15,13 @@ import '../../helpers/helpers.dart';
|
||||
class _MockConvertBloc extends MockBloc<ConvertEvent, ConvertState>
|
||||
implements ConvertBloc {}
|
||||
|
||||
class _MockDownloadBloc extends MockBloc<DownloadEvent, DownloadState>
|
||||
implements DownloadBloc {}
|
||||
|
||||
void main() {
|
||||
group('ShareBody', () {
|
||||
late ConvertBloc convertBloc;
|
||||
late DownloadBloc downloadBloc;
|
||||
late Uint8List thumbnail;
|
||||
|
||||
setUp(() async {
|
||||
@ -27,13 +31,22 @@ void main() {
|
||||
thumbnail = bytesImage!.buffer.asUint8List();
|
||||
when(() => convertBloc.state)
|
||||
.thenReturn(ConvertState(firstFrameProcessed: thumbnail));
|
||||
|
||||
downloadBloc = _MockDownloadBloc();
|
||||
when(() => downloadBloc.state).thenReturn(
|
||||
const DownloadState.initial(videoPath: ''),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'renders SmallShareBody in small layout',
|
||||
(WidgetTester tester) async {
|
||||
tester.setSmallDisplaySize();
|
||||
await tester.pumpSubject(ShareBody(), convertBloc);
|
||||
await tester.pumpSubject(
|
||||
ShareBody(),
|
||||
convertBloc: convertBloc,
|
||||
downloadBloc: downloadBloc,
|
||||
);
|
||||
expect(find.byType(SmallShareBody), findsOneWidget);
|
||||
},
|
||||
);
|
||||
@ -42,18 +55,30 @@ void main() {
|
||||
'renders LargeShareBody in large layout',
|
||||
(WidgetTester tester) async {
|
||||
tester.setLargeDisplaySize();
|
||||
await tester.pumpSubject(ShareBody(), convertBloc);
|
||||
await tester.pumpSubject(
|
||||
ShareBody(),
|
||||
convertBloc: convertBloc,
|
||||
downloadBloc: downloadBloc,
|
||||
);
|
||||
expect(find.byType(LargeShareBody), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('displays a ShareButton', (tester) async {
|
||||
await tester.pumpSubject(ShareBody(), convertBloc);
|
||||
await tester.pumpSubject(
|
||||
ShareBody(),
|
||||
convertBloc: convertBloc,
|
||||
downloadBloc: downloadBloc,
|
||||
);
|
||||
expect(find.byType(ShareButton), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('displays a DownloadButton', (tester) async {
|
||||
await tester.pumpSubject(ShareBody(), convertBloc);
|
||||
await tester.pumpSubject(
|
||||
ShareBody(),
|
||||
convertBloc: convertBloc,
|
||||
downloadBloc: downloadBloc,
|
||||
);
|
||||
expect(
|
||||
find.byType(DownloadButton),
|
||||
findsOneWidget,
|
||||
@ -61,7 +86,11 @@ void main() {
|
||||
});
|
||||
|
||||
testWidgets('displays a RetakeButton', (tester) async {
|
||||
await tester.pumpSubject(ShareBody(), convertBloc);
|
||||
await tester.pumpSubject(
|
||||
ShareBody(),
|
||||
convertBloc: convertBloc,
|
||||
downloadBloc: downloadBloc,
|
||||
);
|
||||
expect(
|
||||
find.byType(RetakeButton),
|
||||
findsOneWidget,
|
||||
@ -71,7 +100,11 @@ void main() {
|
||||
testWidgets(
|
||||
'RetakeButton navigates to photobooth when pressed',
|
||||
(tester) async {
|
||||
await tester.pumpSubject(ShareBody(), convertBloc);
|
||||
await tester.pumpSubject(
|
||||
ShareBody(),
|
||||
convertBloc: convertBloc,
|
||||
downloadBloc: downloadBloc,
|
||||
);
|
||||
final finder = find.byType(RetakeButton);
|
||||
await tester.ensureVisible(finder);
|
||||
await tester.tap(finder);
|
||||
@ -84,9 +117,17 @@ void main() {
|
||||
}
|
||||
|
||||
extension on WidgetTester {
|
||||
Future<void> pumpSubject(ShareBody subject, ConvertBloc bloc) => pumpApp(
|
||||
Future<void> pumpSubject(
|
||||
ShareBody subject, {
|
||||
required ConvertBloc convertBloc,
|
||||
required DownloadBloc downloadBloc,
|
||||
}) =>
|
||||
pumpApp(
|
||||
MultiBlocProvider(
|
||||
providers: [BlocProvider.value(value: bloc)],
|
||||
providers: [
|
||||
BlocProvider.value(value: convertBloc),
|
||||
BlocProvider.value(value: downloadBloc),
|
||||
],
|
||||
child: SingleChildScrollView(child: subject),
|
||||
),
|
||||
);
|
||||
|
Reference in New Issue
Block a user