Voice Chat Fixes & Audio Player Implementation

merge-requests/116/head
Aamir Muhammad 3 years ago
parent fb3b3e8e46
commit 09c401a157

@ -552,8 +552,8 @@ class ChatProviderModel with ChangeNotifier, DiagnosticableTreeMixin {
msg = voiceFile!.path.split("/").last; msg = voiceFile!.path.split("/").last;
} else { } else {
msg = message.text; msg = message.text;
logger.w(msg);
} }
logger.w(jsonEncode(repliedMsg));
SingleUserChatModel data = SingleUserChatModel( SingleUserChatModel data = SingleUserChatModel(
userChatHistoryId: 0, userChatHistoryId: 0,
chatEventId: chatEventId, chatEventId: chatEventId,
@ -1257,51 +1257,6 @@ class ChatProviderModel with ChangeNotifier, DiagnosticableTreeMixin {
return numberStr; return numberStr;
} }
// void playRecoding() async {
// isPlaying = true;
// await playerController.startPlayer(finishMode: FinishMode.pause);
//}
void playVoice(
BuildContext context, {
required SingleUserChatModel data,
}) async {
if (data.voice != null && data.voice!.existsSync()) {
print("Heree");
await data.voiceController!.setFilePath(data!.voice!.path);
await data.voiceController!.setLoopMode(LoopMode.off);
Duration? duration = await data.voiceController!.load();
await data.voiceController!.seek(duration);
await data.voiceController!.play();
} else {
Utils.showLoading(context);
Uint8List encodedString = await ChatApiClient().downloadURL(fileName: data.contant!, fileTypeDescription: getFileTypeDescription(data.fileTypeResponse!.fileTypeName ?? ""));
try {
String path = await downChatVoice(encodedString, data.fileTypeResponse!.fileTypeName ?? "", data);
File file = File(path!);
await file.readAsBytes();
data.voice = file;
Duration? duration = await data.voiceController!.setFilePath(file.path);
await data.voiceController!.setLoopMode(LoopMode.off);
await data.voiceController!.seek(duration);
await data.voiceController!.setVolume(1.0);
await data.voiceController!.load();
Utils.hideLoading(context);
await data.voiceController!.play();
} catch (e) {
Utils.showToast("Cannot open file.");
}
}
}
void pausePlaying(BuildContext context, {required SingleUserChatModel data}) async {
await data.voiceController!.pause();
}
void resumePlaying(BuildContext context, {required SingleUserChatModel data}) async {
await data.voiceController!.play();
}
Future<String> downChatVoice(Uint8List bytes, String ext, SingleUserChatModel data) async { Future<String> downChatVoice(Uint8List bytes, String ext, SingleUserChatModel data) async {
String dirPath = '${(await getApplicationDocumentsDirectory()).path}/chat_audios'; String dirPath = '${(await getApplicationDocumentsDirectory()).path}/chat_audios';
if (!await Directory(dirPath).exists()) { if (!await Directory(dirPath).exists()) {

@ -1,34 +1,48 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:io';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter/services.dart';
import 'package:audio_waveforms/audio_waveforms.dart' as awf; import 'package:audio_waveforms/audio_waveforms.dart' as awf;
import 'package:just_audio/just_audio.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter_svg/flutter_svg.dart';
import 'package:mohem_flutter_app/api/chat/chat_api_client.dart'; import 'package:mohem_flutter_app/api/chat/chat_api_client.dart';
import 'package:mohem_flutter_app/app_state/app_state.dart'; import 'package:mohem_flutter_app/app_state/app_state.dart';
import 'package:mohem_flutter_app/classes/colors.dart'; import 'package:mohem_flutter_app/classes/colors.dart';
import 'package:mohem_flutter_app/classes/utils.dart';
import 'package:mohem_flutter_app/extensions/int_extensions.dart'; import 'package:mohem_flutter_app/extensions/int_extensions.dart';
import 'package:mohem_flutter_app/extensions/string_extensions.dart'; import 'package:mohem_flutter_app/extensions/string_extensions.dart';
import 'package:mohem_flutter_app/extensions/widget_extensions.dart'; import 'package:mohem_flutter_app/extensions/widget_extensions.dart';
import 'package:mohem_flutter_app/models/chat/get_single_user_chat_list_model.dart'; import 'package:mohem_flutter_app/models/chat/get_single_user_chat_list_model.dart';
import 'package:mohem_flutter_app/provider/chat_provider_model.dart'; import 'package:mohem_flutter_app/provider/chat_provider_model.dart';
import 'package:mohem_flutter_app/ui/chat/chat_full_image_preview.dart'; import 'package:mohem_flutter_app/ui/chat/chat_full_image_preview.dart';
import 'package:mohem_flutter_app/ui/chat/common.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:rxdart/rxdart.dart';
import 'package:just_audio/just_audio.dart';
class ChatBubble extends StatelessWidget { class ChatBubble extends StatelessWidget {
ChatBubble({Key? key, required this.dateTime, required this.cItem}) : super(key: key); ChatBubble({Key? key, required this.dateTime, required this.cItem}) : super(key: key);
final String dateTime; final String dateTime;
final SingleUserChatModel cItem; final SingleUserChatModel cItem;
bool isCurrentUser = false; bool isCurrentUser = false;
bool isSeen = false; bool isSeen = false;
bool isReplied = false; bool isReplied = false;
int? fileTypeID; int? fileTypeID;
String? fileTypeName; String? fileTypeName;
late ChatProviderModel provider; late ChatProviderModel provider;
String? fileTypeDescription; String? fileTypeDescription;
bool isDelivered = false; bool isDelivered = false;
String userName = ''; String userName = '';
late Offset screenOffset; late Offset screenOffset;
void makeAssign() { void makeAssign() {
@ -42,6 +56,51 @@ class ChatBubble extends StatelessWidget {
userName = AppState().chatDetails!.response!.userName == cItem.currentUserName.toString() ? "You" : cItem.currentUserName.toString(); userName = AppState().chatDetails!.response!.userName == cItem.currentUserName.toString() ? "You" : cItem.currentUserName.toString();
} }
void playVoice(
BuildContext context, {
required SingleUserChatModel data,
}) async {
if (data.voice != null && data.voice!.existsSync()) {
await data.voiceController!.setFilePath(data!.voice!.path);
await data.voiceController!.setLoopMode(LoopMode.off);
Duration? duration = await data.voiceController!.load();
await data.voiceController!.seek(duration);
await data.voiceController!.play();
} else {
Utils.showLoading(context);
Uint8List encodedString = await ChatApiClient().downloadURL(fileName: data.contant!, fileTypeDescription: provider.getFileTypeDescription(data.fileTypeResponse!.fileTypeName ?? ""));
try {
String path = await provider.downChatVoice(encodedString, data.fileTypeResponse!.fileTypeName ?? "", data);
File file = File(path!);
await file.readAsBytes();
data.voice = file;
Duration? duration = await data.voiceController!.setFilePath(file.path);
await data.voiceController!.setLoopMode(LoopMode.off);
await data.voiceController!.seek(duration);
await data.voiceController!.setVolume(1.0);
await data.voiceController!.load();
Utils.hideLoading(context);
await data.voiceController!.play();
} catch (e) {
Utils.showToast("Cannot open file.");
}
}
}
void pausePlaying(BuildContext context, {required SingleUserChatModel data}) async {
await data.voiceController!.pause();
}
void rePlay(BuildContext context, {required SingleUserChatModel data}) async {
if (data.voice != null && data.voice!.existsSync()) {
await data.voiceController!.seek(Duration.zero);
await data.voiceController!.play();
}
}
Stream<PositionData> get _positionDataStream => Rx.combineLatest3<Duration, Duration, Duration?, PositionData>(cItem.voiceController!.positionStream, cItem.voiceController!.bufferedPositionStream,
cItem.voiceController!.durationStream, (Duration position, Duration bufferedPosition, Duration? duration) => PositionData(position, bufferedPosition, duration ?? Duration.zero));
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
Size windowSize = MediaQuery.of(context).size; Size windowSize = MediaQuery.of(context).size;
@ -77,21 +136,18 @@ class ChatBubble extends StatelessWidget {
.paddingOnly(right: 5, top: 5, bottom: 8, left: 5), .paddingOnly(right: 5, top: 5, bottom: 8, left: 5),
], ],
).expanded, ).expanded,
if (cItem.userChatReplyResponse != null && cItem.userChatReplyResponse!.fileTypeId == 12 || if (cItem.userChatReplyResponse != null)
cItem.userChatReplyResponse!.fileTypeId == 3 || if (cItem.userChatReplyResponse!.fileTypeId == 12 || cItem.userChatReplyResponse!.fileTypeId == 3 || cItem.userChatReplyResponse!.fileTypeId == 4)
cItem.userChatReplyResponse!.fileTypeId == 4) ClipRRect(
ClipRRect( borderRadius: BorderRadius.circular(8.0),
borderRadius: BorderRadius.circular(8.0), child: SizedBox(
child: SizedBox( height: 32,
height: 32, width: 32,
width: 32, child: showImage(
child: showImage( isReplyPreview: false,
isReplyPreview: true,
fileName: cItem.userChatReplyResponse!.contant!, fileName: cItem.userChatReplyResponse!.contant!,
fileTypeDescription: cItem.userChatReplyResponse!.fileTypeResponse!.fileTypeDescription ?? "image/jpg") fileTypeDescription: cItem.userChatReplyResponse!.fileTypeResponse!.fileTypeDescription ?? "image/jpg")),
.paddingOnly(left: 10, right: 10, bottom: 16, top: 16), ).paddingOnly(left: 10, right: 10, bottom: 16, top: 16),
),
),
], ],
), ),
), ),
@ -113,7 +169,7 @@ class ChatBubble extends StatelessWidget {
}), }),
), ),
).paddingOnly(bottom: 4), ).paddingOnly(bottom: 4),
if (fileTypeID == 13) if (fileTypeID == 13 && cItem.voiceController != null)
currentWaveBubble(context, cItem) currentWaveBubble(context, cItem)
else else
Row( Row(
@ -181,20 +237,19 @@ class ChatBubble extends StatelessWidget {
.paddingOnly(right: 5, top: 5, bottom: 8, left: 5), .paddingOnly(right: 5, top: 5, bottom: 8, left: 5),
], ],
).expanded, ).expanded,
if (cItem.userChatReplyResponse != null && cItem.userChatReplyResponse!.fileTypeId == 12 || if (cItem.userChatReplyResponse != null)
cItem.userChatReplyResponse!.fileTypeId == 3 || if (cItem.userChatReplyResponse!.fileTypeId == 12 || cItem.userChatReplyResponse!.fileTypeId == 3 || cItem.userChatReplyResponse!.fileTypeId == 4)
cItem.userChatReplyResponse!.fileTypeId == 4) ClipRRect(
ClipRRect( borderRadius: BorderRadius.circular(8.0),
borderRadius: BorderRadius.circular(8.0), child: SizedBox(
child: SizedBox( height: 32,
height: 32, width: 32,
width: 32, child: showImage(
child: showImage( isReplyPreview: true,
isReplyPreview: true, fileName: cItem.userChatReplyResponse!.contant!,
fileName: cItem.userChatReplyResponse!.contant!, fileTypeDescription: cItem.userChatReplyResponse!.fileTypeResponse!.fileTypeDescription ?? "image/jpg"),
fileTypeDescription: cItem.userChatReplyResponse!.fileTypeResponse!.fileTypeDescription ?? "image/jpg"), ),
), ).paddingOnly(left: 10, right: 10, bottom: 16, top: 16)
).paddingOnly(left: 10, right: 10, bottom: 16, top: 16)
], ],
), ),
), ),
@ -216,7 +271,7 @@ class ChatBubble extends StatelessWidget {
}), }),
), ),
).paddingOnly(bottom: 4), ).paddingOnly(bottom: 4),
if (fileTypeID == 13) if (fileTypeID == 13 && cItem.voiceController != null)
recipetWaveBubble(context, cItem) recipetWaveBubble(context, cItem)
else else
Row( Row(
@ -293,15 +348,18 @@ class ChatBubble extends StatelessWidget {
child: Row( child: Row(
children: [ children: [
getPlayer(player: data.voiceController!, modelData: data), getPlayer(player: data.voiceController!, modelData: data),
Slider( StreamBuilder<PositionData>(
activeColor: Colors.white, stream: _positionDataStream,
inactiveColor: Colors.grey, builder: (BuildContext context, AsyncSnapshot<PositionData> snapshot) {
value: 0.toDouble(), PositionData? positionData = snapshot.data;
max: 50.toDouble(), return SeekBar(
onChanged: (double value) { duration: positionData?.duration ?? Duration.zero,
// Add code to track the music duration. position: positionData?.position ?? Duration.zero,
bufferedPosition: positionData?.bufferedPosition ?? Duration.zero,
onChangeEnd: data.voiceController!.seek,
).expanded;
}, },
).expanded, ),
], ],
), ),
).circle(5); ).circle(5);
@ -320,15 +378,18 @@ class ChatBubble extends StatelessWidget {
mainAxisSize: MainAxisSize.max, mainAxisSize: MainAxisSize.max,
children: [ children: [
getPlayer(player: data.voiceController!, modelData: data), getPlayer(player: data.voiceController!, modelData: data),
Slider( StreamBuilder<PositionData>(
activeColor: Colors.white, stream: _positionDataStream,
inactiveColor: Colors.grey, builder: (BuildContext context, AsyncSnapshot<PositionData> snapshot) {
value: 0.toDouble(), PositionData? positionData = snapshot.data;
max: 50.toDouble(), return SeekBar(
onChanged: (double value) { duration: positionData?.duration ?? Duration.zero,
// Add code to track the music duration. position: positionData?.position ?? Duration.zero,
bufferedPosition: positionData?.bufferedPosition ?? Duration.zero,
onChangeEnd: data.voiceController!.seek,
).expanded;
}, },
).expanded, ),
], ],
), ),
).circle(5); ).circle(5);
@ -354,7 +415,7 @@ class ChatBubble extends StatelessWidget {
size: 30, size: 30,
color: MyColors.lightGreenColor, color: MyColors.lightGreenColor,
).onPress(() { ).onPress(() {
provider.playVoice(context, data: modelData); playVoice(context, data: modelData);
}); });
} else if (processingState != ProcessingState.completed) { } else if (processingState != ProcessingState.completed) {
return Icon( return Icon(
@ -362,7 +423,7 @@ class ChatBubble extends StatelessWidget {
size: 30, size: 30,
color: MyColors.lightGreenColor, color: MyColors.lightGreenColor,
).onPress(() { ).onPress(() {
provider.pausePlaying(context, data: modelData); pausePlaying(context, data: modelData);
}); });
} else { } else {
return Icon( return Icon(
@ -370,7 +431,7 @@ class ChatBubble extends StatelessWidget {
size: 30, size: 30,
color: MyColors.lightGreenColor, color: MyColors.lightGreenColor,
).onPress(() { ).onPress(() {
player!.seek(Duration.zero); rePlay(context, data: modelData);
}); });
} }
}, },
@ -378,64 +439,4 @@ class ChatBubble extends StatelessWidget {
} }
} }
class WaveBubble extends StatelessWidget {
final awf.PlayerController playerController;
final VoidCallback onTap;
final bool isPlaying;
const WaveBubble({
Key? key,
required this.playerController,
required this.onTap,
required this.isPlaying,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.all(10),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(30),
gradient: const LinearGradient(
transform: GradientRotation(.83),
begin: Alignment.topRight,
end: Alignment.bottomLeft,
colors: <Color>[
MyColors.gradiantEndColor,
MyColors.gradiantStartColor,
],
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
onPressed: onTap,
icon: Icon(isPlaying ? Icons.stop : Icons.play_arrow),
color: Colors.white,
splashColor: Colors.transparent,
highlightColor: Colors.transparent,
),
awf.AudioFileWaveforms(
size: Size(MediaQuery.of(context).size.width / 2, 10),
playerController: playerController,
padding: EdgeInsets.zero,
margin: EdgeInsets.zero,
enableSeekGesture: true,
density: 1,
playerWaveStyle: const awf.PlayerWaveStyle(
fixedWaveColor: Colors.white,
liveWaveColor: MyColors.greenColor,
showTop: true,
showBottom: true,
waveCap: StrokeCap.round,
seekLineThickness: 2,
visualizerHeight: 4,
backgroundColor: Colors.transparent,
),
),
],
),
);
}
}

@ -1,6 +1,4 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert';
import 'package:audio_waveforms/audio_waveforms.dart'; import 'package:audio_waveforms/audio_waveforms.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -18,7 +16,7 @@ import 'package:mohem_flutter_app/models/chat/get_single_user_chat_list_model.da
import 'package:mohem_flutter_app/provider/chat_provider_model.dart'; import 'package:mohem_flutter_app/provider/chat_provider_model.dart';
import 'package:mohem_flutter_app/ui/chat/call/chat_outgoing_call_screen.dart'; import 'package:mohem_flutter_app/ui/chat/call/chat_outgoing_call_screen.dart';
import 'package:mohem_flutter_app/ui/chat/chat_bubble.dart'; import 'package:mohem_flutter_app/ui/chat/chat_bubble.dart';
import 'package:mohem_flutter_app/widgets/app_bar_widget.dart'; import 'package:mohem_flutter_app/ui/chat/common.dart';
import 'package:mohem_flutter_app/widgets/chat_app_bar_widge.dart'; import 'package:mohem_flutter_app/widgets/chat_app_bar_widge.dart';
import 'package:mohem_flutter_app/widgets/shimmer/dashboard_shimmer_widget.dart'; import 'package:mohem_flutter_app/widgets/shimmer/dashboard_shimmer_widget.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';

@ -81,6 +81,7 @@ class _ChatHomeState extends State<ChatHome> {
children: <Widget>[ children: <Widget>[
myTab(LocaleKeys.mychats.tr(), 0), myTab(LocaleKeys.mychats.tr(), 0),
myTab(LocaleKeys.favorite.tr(), 1), myTab(LocaleKeys.favorite.tr(), 1),
myTab("My Team", 2),
], ],
), ),
), ),
@ -95,6 +96,7 @@ class _ChatHomeState extends State<ChatHome> {
children: <Widget>[ children: <Widget>[
ChatHomeScreen(), ChatHomeScreen(),
ChatFavoriteUsersScreen(), ChatFavoriteUsersScreen(),
ChatFavoriteUsersScreen(),
], ],
).expanded, ).expanded,
], ],

@ -0,0 +1,189 @@
import 'dart:math';
import 'package:audio_waveforms/audio_waveforms.dart';
import 'package:flutter/material.dart';
import 'package:mohem_flutter_app/classes/colors.dart';
class SeekBar extends StatefulWidget {
final Duration duration;
final Duration position;
final Duration bufferedPosition;
final ValueChanged<Duration>? onChanged;
final ValueChanged<Duration>? onChangeEnd;
const SeekBar({
Key? key,
required this.duration,
required this.position,
required this.bufferedPosition,
this.onChanged,
this.onChangeEnd,
}) : super(key: key);
@override
SeekBarState createState() => SeekBarState();
}
class SeekBarState extends State<SeekBar> {
double? _dragValue;
late SliderThemeData _sliderThemeData;
@override
void didChangeDependencies() {
super.didChangeDependencies();
_sliderThemeData = SliderTheme.of(context).copyWith(
// trackHeight: 2.0,
thumbColor: MyColors.lightGreenColor,
activeTrackColor: MyColors.lightGreenColor,
inactiveTrackColor: MyColors.grey57Color.withOpacity(0.4),
);
}
@override
Widget build(BuildContext context) {
return Stack(
children: [
SliderTheme(
data: _sliderThemeData.copyWith(
thumbShape: HiddenThumbComponentShape(),
),
child: ExcludeSemantics(
child: Slider(
min: 0.0,
max: widget.duration.inMilliseconds.toDouble(),
value: min(widget.bufferedPosition.inMilliseconds.toDouble(), widget.duration.inMilliseconds.toDouble()),
onChanged: (value) {
setState(() {
_dragValue = value;
});
if (widget.onChanged != null) {
widget.onChanged!(Duration(milliseconds: value.round()));
}
},
onChangeEnd: (value) {
if (widget.onChangeEnd != null) {
widget.onChangeEnd!(Duration(milliseconds: value.round()));
}
_dragValue = null;
},
),
),
),
SliderTheme(
data: _sliderThemeData.copyWith(
inactiveTrackColor: Colors.transparent,
),
child: Slider(
min: 0.0,
max: widget.duration.inMilliseconds.toDouble(),
value: min(_dragValue ?? widget.position.inMilliseconds.toDouble(), widget.duration.inMilliseconds.toDouble()),
onChanged: (value) {
setState(() {
_dragValue = value;
});
if (widget.onChanged != null) {
widget.onChanged!(Duration(milliseconds: value.round()));
}
},
onChangeEnd: (value) {
if (widget.onChangeEnd != null) {
widget.onChangeEnd!(Duration(milliseconds: value.round()));
}
_dragValue = null;
},
),
),
],
);
}
}
class PositionData {
final Duration position;
final Duration bufferedPosition;
final Duration duration;
PositionData(this.position, this.bufferedPosition, this.duration);
}
class HiddenThumbComponentShape extends SliderComponentShape {
@override
Size getPreferredSize(bool isEnabled, bool isDiscrete) => Size.zero;
@override
void paint(
PaintingContext context,
Offset center, {
required Animation<double> activationAnimation,
required Animation<double> enableAnimation,
required bool isDiscrete,
required TextPainter labelPainter,
required RenderBox parentBox,
required SliderThemeData sliderTheme,
required TextDirection textDirection,
required double value,
required double textScaleFactor,
required Size sizeWithOverflow,
}) {}
}
class WaveBubble extends StatelessWidget {
final PlayerController playerController;
final VoidCallback onTap;
final bool isPlaying;
const WaveBubble({
Key? key,
required this.playerController,
required this.onTap,
required this.isPlaying,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.all(10),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(30),
gradient: const LinearGradient(
transform: GradientRotation(.83),
begin: Alignment.topRight,
end: Alignment.bottomLeft,
colors: <Color>[
MyColors.gradiantEndColor,
MyColors.gradiantStartColor,
],
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
onPressed: onTap,
icon: Icon(isPlaying ? Icons.stop : Icons.play_arrow),
color: Colors.white,
splashColor: Colors.transparent,
highlightColor: Colors.transparent,
),
AudioFileWaveforms(
size: Size(MediaQuery.of(context).size.width / 2, 10),
playerController: playerController,
padding: EdgeInsets.zero,
margin: EdgeInsets.zero,
enableSeekGesture: true,
density: 1,
playerWaveStyle: const PlayerWaveStyle(
fixedWaveColor: Colors.white,
liveWaveColor: MyColors.greenColor,
showTop: true,
showBottom: true,
waveCap: StrokeCap.round,
seekLineThickness: 2,
visualizerHeight: 4,
backgroundColor: Colors.transparent,
),
),
],
),
);
}
}

@ -94,9 +94,8 @@ dependencies:
camera: ^0.10.0+4 camera: ^0.10.0+4
#Chat Voice Message Recoding & Play #Chat Voice Message Recoding & Play
# record: ^4.4.3
audio_waveforms: ^0.1.5+1 audio_waveforms: ^0.1.5+1
# animated_text_kit: ^4.2.2 rxdart: ^0.27.7
#Encryption #Encryption
flutter_des: ^2.1.0 flutter_des: ^2.1.0
@ -106,6 +105,7 @@ dependencies:
safe_device: ^1.1.2 safe_device: ^1.1.2
flutter_layout_grid: ^2.0.1 flutter_layout_grid: ^2.0.1
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
sdk: flutter sdk: flutter

Loading…
Cancel
Save