mirror of
				https://gitlab.com/famedly/fluffychat.git
				synced 2025-11-04 06:17:26 +01:00 
			
		
		
		
	feat: Recording dialog with displaying amplitude
This commit is contained in:
		
							parent
							
								
									0c4d1ec3f8
								
							
						
					
					
						commit
						964515d76c
					
				@ -422,6 +422,10 @@ class ChatController extends State<Chat> {
 | 
			
		||||
          'duration': result.duration,
 | 
			
		||||
        },
 | 
			
		||||
        'org.matrix.msc3245.voice': {},
 | 
			
		||||
        'org.matrix.msc1767.audio': {
 | 
			
		||||
          'duration': result.duration,
 | 
			
		||||
          'waveform': result.waveform,
 | 
			
		||||
        },
 | 
			
		||||
      }),
 | 
			
		||||
    );
 | 
			
		||||
    setState(() {
 | 
			
		||||
 | 
			
		||||
@ -1,3 +1,5 @@
 | 
			
		||||
//@dart=2.12
 | 
			
		||||
 | 
			
		||||
import 'dart:async';
 | 
			
		||||
import 'dart:io';
 | 
			
		||||
 | 
			
		||||
@ -8,6 +10,7 @@ import 'package:flutter_gen/gen_l10n/l10n.dart';
 | 
			
		||||
import 'package:matrix/matrix.dart';
 | 
			
		||||
import 'package:path_provider/path_provider.dart';
 | 
			
		||||
 | 
			
		||||
import 'package:fluffychat/config/app_config.dart';
 | 
			
		||||
import 'package:fluffychat/utils/sentry_controller.dart';
 | 
			
		||||
import '../../../utils/matrix_sdk_extensions.dart/event_extension.dart';
 | 
			
		||||
 | 
			
		||||
@ -15,9 +18,9 @@ class AudioPlayerWidget extends StatefulWidget {
 | 
			
		||||
  final Color color;
 | 
			
		||||
  final Event event;
 | 
			
		||||
 | 
			
		||||
  static String currentId;
 | 
			
		||||
  static String? currentId;
 | 
			
		||||
 | 
			
		||||
  const AudioPlayerWidget(this.event, {this.color = Colors.black, Key key})
 | 
			
		||||
  const AudioPlayerWidget(this.event, {this.color = Colors.black, Key? key})
 | 
			
		||||
      : super(key: key);
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
@ -30,16 +33,16 @@ class _AudioPlayerState extends State<AudioPlayerWidget> {
 | 
			
		||||
  AudioPlayerStatus status = AudioPlayerStatus.notDownloaded;
 | 
			
		||||
  final AudioPlayer audioPlayer = AudioPlayer();
 | 
			
		||||
 | 
			
		||||
  StreamSubscription onAudioPositionChanged;
 | 
			
		||||
  StreamSubscription onDurationChanged;
 | 
			
		||||
  StreamSubscription onPlayerStateChanged;
 | 
			
		||||
  StreamSubscription onPlayerError;
 | 
			
		||||
  StreamSubscription? onAudioPositionChanged;
 | 
			
		||||
  StreamSubscription? onDurationChanged;
 | 
			
		||||
  StreamSubscription? onPlayerStateChanged;
 | 
			
		||||
  StreamSubscription? onPlayerError;
 | 
			
		||||
 | 
			
		||||
  String statusText;
 | 
			
		||||
  double currentPosition = 0;
 | 
			
		||||
  String? statusText;
 | 
			
		||||
  int currentPosition = 0;
 | 
			
		||||
  double maxPosition = 0;
 | 
			
		||||
 | 
			
		||||
  File audioFile;
 | 
			
		||||
  File? audioFile;
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  void dispose() {
 | 
			
		||||
@ -60,6 +63,7 @@ class _AudioPlayerState extends State<AudioPlayerWidget> {
 | 
			
		||||
    try {
 | 
			
		||||
      final matrixFile =
 | 
			
		||||
          await widget.event.downloadAndDecryptAttachmentCached();
 | 
			
		||||
      if (matrixFile == null) throw ('Download failed');
 | 
			
		||||
      final tempDir = await getTemporaryDirectory();
 | 
			
		||||
      final fileName =
 | 
			
		||||
          widget.event.content.tryGet<String>('filename') ?? matrixFile.name;
 | 
			
		||||
@ -86,7 +90,7 @@ class _AudioPlayerState extends State<AudioPlayerWidget> {
 | 
			
		||||
      if (AudioPlayerWidget.currentId != null) {
 | 
			
		||||
        if (audioPlayer.state != PlayerState.STOPPED) {
 | 
			
		||||
          await audioPlayer.stop();
 | 
			
		||||
          setState(() => null);
 | 
			
		||||
          setState(() {});
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
      AudioPlayerWidget.currentId = widget.event.eventId;
 | 
			
		||||
@ -105,30 +109,31 @@ class _AudioPlayerState extends State<AudioPlayerWidget> {
 | 
			
		||||
          setState(() {
 | 
			
		||||
            statusText =
 | 
			
		||||
                '${state.inMinutes.toString().padLeft(2, '0')}:${(state.inSeconds % 60).toString().padLeft(2, '0')}';
 | 
			
		||||
            currentPosition = state.inMilliseconds.toDouble();
 | 
			
		||||
            currentPosition =
 | 
			
		||||
                ((state.inMilliseconds.toDouble() / maxPosition) * 100).round();
 | 
			
		||||
          });
 | 
			
		||||
        });
 | 
			
		||||
        onDurationChanged ??= audioPlayer.onDurationChanged.listen((max) =>
 | 
			
		||||
            setState(() => maxPosition = max.inMilliseconds.toDouble()));
 | 
			
		||||
        onPlayerStateChanged ??= audioPlayer.onPlayerStateChanged
 | 
			
		||||
            .listen((_) => setState(() => null));
 | 
			
		||||
        onPlayerStateChanged ??=
 | 
			
		||||
            audioPlayer.onPlayerStateChanged.listen((_) => setState(() {}));
 | 
			
		||||
        onPlayerError ??= audioPlayer.onPlayerError.listen((e) {
 | 
			
		||||
          ScaffoldMessenger.of(context).showSnackBar(
 | 
			
		||||
            SnackBar(
 | 
			
		||||
              content: Text(L10n.of(context).oopsSomethingWentWrong),
 | 
			
		||||
              content: Text(L10n.of(context)!.oopsSomethingWentWrong),
 | 
			
		||||
            ),
 | 
			
		||||
          );
 | 
			
		||||
          SentryController.captureException(e, StackTrace.current);
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        await audioPlayer.play(audioFile.path);
 | 
			
		||||
        await audioPlayer.play(audioFile!.path);
 | 
			
		||||
        break;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  static const double buttonSize = 36;
 | 
			
		||||
 | 
			
		||||
  String get _durationString {
 | 
			
		||||
  String? get _durationString {
 | 
			
		||||
    final durationInt = widget.event.content
 | 
			
		||||
        .tryGetMap<String, dynamic>('info')
 | 
			
		||||
        ?.tryGet<int>('duration');
 | 
			
		||||
@ -137,9 +142,30 @@ class _AudioPlayerState extends State<AudioPlayerWidget> {
 | 
			
		||||
    return '${duration.inMinutes.toString().padLeft(2, '0')}:${(duration.inSeconds % 60).toString().padLeft(2, '0')}';
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  List<int> get waveform {
 | 
			
		||||
    final eventWaveForm = widget.event.content
 | 
			
		||||
        .tryGetMap<String, dynamic>('org.matrix.msc1767.audio')
 | 
			
		||||
        ?.tryGetList<int>('waveform');
 | 
			
		||||
    if (eventWaveForm == null) {
 | 
			
		||||
      return List<int>.filled(100, 500);
 | 
			
		||||
    }
 | 
			
		||||
    while (eventWaveForm.length < 100) {
 | 
			
		||||
      for (var i = 0; i < eventWaveForm.length; i = i + 2) {
 | 
			
		||||
        eventWaveForm.insert(i, eventWaveForm[i]);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    var i = 0;
 | 
			
		||||
    final step = (eventWaveForm.length / 100).round();
 | 
			
		||||
    while (eventWaveForm.length > 100) {
 | 
			
		||||
      eventWaveForm.removeAt(i);
 | 
			
		||||
      i = (i + step) % 100;
 | 
			
		||||
    }
 | 
			
		||||
    return eventWaveForm;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    statusText ??= _durationString ?? '00:00';
 | 
			
		||||
    final statusText = this.statusText ??= _durationString ?? '00:00';
 | 
			
		||||
    return Padding(
 | 
			
		||||
      padding: const EdgeInsets.symmetric(horizontal: 6.0),
 | 
			
		||||
      child: Row(
 | 
			
		||||
@ -172,21 +198,40 @@ class _AudioPlayerState extends State<AudioPlayerWidget> {
 | 
			
		||||
                    },
 | 
			
		||||
                  ),
 | 
			
		||||
          ),
 | 
			
		||||
          const SizedBox(width: 8),
 | 
			
		||||
          Expanded(
 | 
			
		||||
            child: Slider(
 | 
			
		||||
              activeColor: Theme.of(context).colorScheme.secondaryVariant,
 | 
			
		||||
              inactiveColor: widget.color.withAlpha(64),
 | 
			
		||||
              value: currentPosition,
 | 
			
		||||
              onChanged: (double position) =>
 | 
			
		||||
                  audioPlayer.seek(Duration(milliseconds: position.toInt())),
 | 
			
		||||
              max: status == AudioPlayerStatus.downloaded ? maxPosition : 0,
 | 
			
		||||
              min: 0,
 | 
			
		||||
            child: Row(
 | 
			
		||||
              children: [
 | 
			
		||||
                for (var i = 0; i < 100; i++)
 | 
			
		||||
                  Expanded(
 | 
			
		||||
                    child: InkWell(
 | 
			
		||||
                      onTap: () => audioPlayer.seek(Duration(
 | 
			
		||||
                          milliseconds: (maxPosition / 100).round() * i)),
 | 
			
		||||
                      child: Opacity(
 | 
			
		||||
                        opacity: currentPosition > i ? 1 : 0.5,
 | 
			
		||||
                        child: Container(
 | 
			
		||||
                            margin: const EdgeInsets.only(left: 2),
 | 
			
		||||
                            decoration: BoxDecoration(
 | 
			
		||||
                              color: Theme.of(context).colorScheme.primary,
 | 
			
		||||
                              borderRadius:
 | 
			
		||||
                                  BorderRadius.circular(AppConfig.borderRadius),
 | 
			
		||||
                            ),
 | 
			
		||||
                            height: 64 * (waveform[i] / 1024)),
 | 
			
		||||
                      ),
 | 
			
		||||
                    ),
 | 
			
		||||
                  )
 | 
			
		||||
              ],
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
          Text(
 | 
			
		||||
            statusText,
 | 
			
		||||
            style: TextStyle(
 | 
			
		||||
              color: widget.color,
 | 
			
		||||
          const SizedBox(width: 8),
 | 
			
		||||
          Container(
 | 
			
		||||
            alignment: Alignment.centerRight,
 | 
			
		||||
            width: 42,
 | 
			
		||||
            child: Text(
 | 
			
		||||
              statusText,
 | 
			
		||||
              style: TextStyle(
 | 
			
		||||
                color: widget.color,
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
        ],
 | 
			
		||||
 | 
			
		||||
@ -1,3 +1,5 @@
 | 
			
		||||
//@dart=2.12
 | 
			
		||||
 | 
			
		||||
import 'dart:async';
 | 
			
		||||
 | 
			
		||||
import 'package:flutter/cupertino.dart';
 | 
			
		||||
@ -8,13 +10,14 @@ import 'package:path_provider/path_provider.dart';
 | 
			
		||||
import 'package:record/record.dart';
 | 
			
		||||
import 'package:wakelock/wakelock.dart';
 | 
			
		||||
 | 
			
		||||
import 'package:fluffychat/config/app_config.dart';
 | 
			
		||||
import 'package:fluffychat/utils/platform_infos.dart';
 | 
			
		||||
import 'package:fluffychat/utils/sentry_controller.dart';
 | 
			
		||||
 | 
			
		||||
class RecordingDialog extends StatefulWidget {
 | 
			
		||||
  static const String recordingFileType = 'm4a';
 | 
			
		||||
  const RecordingDialog({
 | 
			
		||||
    Key key,
 | 
			
		||||
    Key? key,
 | 
			
		||||
  }) : super(key: key);
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
@ -22,13 +25,13 @@ class RecordingDialog extends StatefulWidget {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _RecordingDialogState extends State<RecordingDialog> {
 | 
			
		||||
  Timer _recorderSubscription;
 | 
			
		||||
  Timer? _recorderSubscription;
 | 
			
		||||
  Duration _duration = Duration.zero;
 | 
			
		||||
 | 
			
		||||
  bool error = false;
 | 
			
		||||
  String _recordedPath;
 | 
			
		||||
  String? _recordedPath;
 | 
			
		||||
  final _audioRecorder = Record();
 | 
			
		||||
  Amplitude _amplitude;
 | 
			
		||||
  final List<double> amplitudeTimeline = [];
 | 
			
		||||
 | 
			
		||||
  static const int bitRate = 64000;
 | 
			
		||||
  static const double samplingRate = 22050.0;
 | 
			
		||||
@ -55,7 +58,10 @@ class _RecordingDialogState extends State<RecordingDialog> {
 | 
			
		||||
      _recorderSubscription?.cancel();
 | 
			
		||||
      _recorderSubscription =
 | 
			
		||||
          Timer.periodic(const Duration(milliseconds: 100), (_) async {
 | 
			
		||||
        _amplitude = await _audioRecorder.getAmplitude();
 | 
			
		||||
        final amplitude = await _audioRecorder.getAmplitude();
 | 
			
		||||
        var value = 100 + amplitude.current * 2;
 | 
			
		||||
        value = value < 1 ? 1 : value;
 | 
			
		||||
        amplitudeTimeline.add(value);
 | 
			
		||||
        setState(() {
 | 
			
		||||
          _duration += const Duration(milliseconds: 100);
 | 
			
		||||
        });
 | 
			
		||||
@ -83,52 +89,64 @@ class _RecordingDialogState extends State<RecordingDialog> {
 | 
			
		||||
  void _stopAndSend() async {
 | 
			
		||||
    _recorderSubscription?.cancel();
 | 
			
		||||
    await _audioRecorder.stop();
 | 
			
		||||
    Navigator.of(context, rootNavigator: false)
 | 
			
		||||
        .pop<RecordingResult>(RecordingResult(
 | 
			
		||||
      path: _recordedPath,
 | 
			
		||||
      duration: _duration.inMilliseconds,
 | 
			
		||||
    ));
 | 
			
		||||
    final path = _recordedPath;
 | 
			
		||||
    if (path == null) throw ('Recording failed!');
 | 
			
		||||
    final step = amplitudeTimeline.length < 100
 | 
			
		||||
        ? 1
 | 
			
		||||
        : (amplitudeTimeline.length / 100).round();
 | 
			
		||||
    final waveform = <int>[];
 | 
			
		||||
    for (var i = 0; i < amplitudeTimeline.length; i += step) {
 | 
			
		||||
      waveform.add((amplitudeTimeline[i] / 100 * 1024).round());
 | 
			
		||||
    }
 | 
			
		||||
    Navigator.of(context, rootNavigator: false).pop<RecordingResult>(
 | 
			
		||||
      RecordingResult(
 | 
			
		||||
        path: path,
 | 
			
		||||
        duration: _duration.inMilliseconds,
 | 
			
		||||
        waveform: waveform,
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    const maxDecibalWidth = 64.0;
 | 
			
		||||
    final decibalWidth =
 | 
			
		||||
        ((_amplitude == null || _amplitude.current == double.negativeInfinity
 | 
			
		||||
                        ? 0
 | 
			
		||||
                        : 1 / _amplitude.current / _amplitude.max)
 | 
			
		||||
                    .abs() +
 | 
			
		||||
                2) *
 | 
			
		||||
            (maxDecibalWidth / 4).toDouble();
 | 
			
		||||
    final time =
 | 
			
		||||
        '${_duration.inMinutes.toString().padLeft(2, '0')}:${(_duration.inSeconds % 60).toString().padLeft(2, '0')}';
 | 
			
		||||
    final content = error
 | 
			
		||||
        ? Text(L10n.of(context).oopsSomethingWentWrong)
 | 
			
		||||
        ? Text(L10n.of(context)!.oopsSomethingWentWrong)
 | 
			
		||||
        : Row(
 | 
			
		||||
            children: <Widget>[
 | 
			
		||||
            children: [
 | 
			
		||||
              Container(
 | 
			
		||||
                width: maxDecibalWidth,
 | 
			
		||||
                height: maxDecibalWidth,
 | 
			
		||||
                alignment: Alignment.center,
 | 
			
		||||
                child: AnimatedContainer(
 | 
			
		||||
                  duration: const Duration(milliseconds: 100),
 | 
			
		||||
                  width: decibalWidth,
 | 
			
		||||
                  height: decibalWidth,
 | 
			
		||||
                  decoration: BoxDecoration(
 | 
			
		||||
                    color: Colors.red,
 | 
			
		||||
                    borderRadius: BorderRadius.circular(decibalWidth),
 | 
			
		||||
                  ),
 | 
			
		||||
                width: 16,
 | 
			
		||||
                height: 16,
 | 
			
		||||
                decoration: BoxDecoration(
 | 
			
		||||
                  borderRadius: BorderRadius.circular(32),
 | 
			
		||||
                  color: Colors.red,
 | 
			
		||||
                ),
 | 
			
		||||
              ),
 | 
			
		||||
              const SizedBox(width: 8),
 | 
			
		||||
              Expanded(
 | 
			
		||||
                child: Text(
 | 
			
		||||
                  '${L10n.of(context).recording}: $time',
 | 
			
		||||
                  style: const TextStyle(
 | 
			
		||||
                    fontSize: 18,
 | 
			
		||||
                child: Center(
 | 
			
		||||
                  child: Row(
 | 
			
		||||
                    mainAxisSize: MainAxisSize.min,
 | 
			
		||||
                    mainAxisAlignment: MainAxisAlignment.end,
 | 
			
		||||
                    children: amplitudeTimeline.reversed
 | 
			
		||||
                        .take(26)
 | 
			
		||||
                        .toList()
 | 
			
		||||
                        .reversed
 | 
			
		||||
                        .map((amplitude) => Container(
 | 
			
		||||
                            margin: const EdgeInsets.only(left: 2),
 | 
			
		||||
                            width: 4,
 | 
			
		||||
                            decoration: BoxDecoration(
 | 
			
		||||
                              color: Theme.of(context).colorScheme.primary,
 | 
			
		||||
                              borderRadius:
 | 
			
		||||
                                  BorderRadius.circular(AppConfig.borderRadius),
 | 
			
		||||
                            ),
 | 
			
		||||
                            height: maxDecibalWidth * (amplitude / 100)))
 | 
			
		||||
                        .toList(),
 | 
			
		||||
                  ),
 | 
			
		||||
                ),
 | 
			
		||||
              ),
 | 
			
		||||
              Text(time),
 | 
			
		||||
            ],
 | 
			
		||||
          );
 | 
			
		||||
    if (PlatformInfos.isCupertinoStyle) {
 | 
			
		||||
@ -138,17 +156,20 @@ class _RecordingDialogState extends State<RecordingDialog> {
 | 
			
		||||
          CupertinoDialogAction(
 | 
			
		||||
            onPressed: () => Navigator.of(context, rootNavigator: false).pop(),
 | 
			
		||||
            child: Text(
 | 
			
		||||
              L10n.of(context).cancel.toUpperCase(),
 | 
			
		||||
              L10n.of(context)!.cancel.toUpperCase(),
 | 
			
		||||
              style: TextStyle(
 | 
			
		||||
                color:
 | 
			
		||||
                    Theme.of(context).textTheme.bodyText2.color.withAlpha(150),
 | 
			
		||||
                color: Theme.of(context)
 | 
			
		||||
                    .textTheme
 | 
			
		||||
                    .bodyText2
 | 
			
		||||
                    ?.color
 | 
			
		||||
                    ?.withAlpha(150),
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
          if (error != true)
 | 
			
		||||
            CupertinoDialogAction(
 | 
			
		||||
              onPressed: _stopAndSend,
 | 
			
		||||
              child: Text(L10n.of(context).send.toUpperCase()),
 | 
			
		||||
              child: Text(L10n.of(context)!.send.toUpperCase()),
 | 
			
		||||
            ),
 | 
			
		||||
        ],
 | 
			
		||||
      );
 | 
			
		||||
@ -159,9 +180,10 @@ class _RecordingDialogState extends State<RecordingDialog> {
 | 
			
		||||
        TextButton(
 | 
			
		||||
          onPressed: () => Navigator.of(context, rootNavigator: false).pop(),
 | 
			
		||||
          child: Text(
 | 
			
		||||
            L10n.of(context).cancel.toUpperCase(),
 | 
			
		||||
            L10n.of(context)!.cancel.toUpperCase(),
 | 
			
		||||
            style: TextStyle(
 | 
			
		||||
              color: Theme.of(context).textTheme.bodyText2.color.withAlpha(150),
 | 
			
		||||
              color:
 | 
			
		||||
                  Theme.of(context).textTheme.bodyText2?.color?.withAlpha(150),
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
        ),
 | 
			
		||||
@ -171,7 +193,7 @@ class _RecordingDialogState extends State<RecordingDialog> {
 | 
			
		||||
            child: Row(
 | 
			
		||||
              mainAxisSize: MainAxisSize.min,
 | 
			
		||||
              children: <Widget>[
 | 
			
		||||
                Text(L10n.of(context).send.toUpperCase()),
 | 
			
		||||
                Text(L10n.of(context)!.send.toUpperCase()),
 | 
			
		||||
                const SizedBox(width: 4),
 | 
			
		||||
                const Icon(Icons.send_outlined, size: 15),
 | 
			
		||||
              ],
 | 
			
		||||
@ -185,20 +207,24 @@ class _RecordingDialogState extends State<RecordingDialog> {
 | 
			
		||||
class RecordingResult {
 | 
			
		||||
  final String path;
 | 
			
		||||
  final int duration;
 | 
			
		||||
  final List<int> waveform;
 | 
			
		||||
 | 
			
		||||
  const RecordingResult({
 | 
			
		||||
    @required this.path,
 | 
			
		||||
    @required this.duration,
 | 
			
		||||
    required this.path,
 | 
			
		||||
    required this.duration,
 | 
			
		||||
    required this.waveform,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  factory RecordingResult.fromJson(Map<String, dynamic> json) =>
 | 
			
		||||
      RecordingResult(
 | 
			
		||||
        path: json['path'],
 | 
			
		||||
        duration: json['duration'],
 | 
			
		||||
        waveform: List<int>.from(json['waveform']),
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
  Map<String, dynamic> toJson() => {
 | 
			
		||||
        'path': path,
 | 
			
		||||
        'duration': duration,
 | 
			
		||||
        'waveform': waveform,
 | 
			
		||||
      };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -61,7 +61,7 @@ dependencies:
 | 
			
		||||
  qr_code_scanner: ^0.6.1
 | 
			
		||||
  qr_flutter: ^4.0.0
 | 
			
		||||
  receive_sharing_intent: ^1.4.5
 | 
			
		||||
  record: ^3.0.0
 | 
			
		||||
  record: ^3.0.2
 | 
			
		||||
  salomon_bottom_bar: ^3.1.0
 | 
			
		||||
  scroll_to_index: ^2.1.0
 | 
			
		||||
  sentry: ^6.0.1
 | 
			
		||||
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user