D1APP-31 message box (full audio support)

This commit is contained in:
MohammadTaha Basiri 2022-01-03 19:43:45 +03:30
parent 98e1e50891
commit 0ec05f1764
11 changed files with 572 additions and 102 deletions

View File

@ -82,8 +82,8 @@ class DarkThemeConfig {
colorScheme: _colorScheme, colorScheme: _colorScheme,
fontFamily: 'Dana-FA', fontFamily: 'Dana-FA',
textTheme: _textTheme, textTheme: _textTheme,
iconTheme: const IconThemeData( iconTheme: IconThemeData(
color: text, color: _colorScheme.focusedBorder,
), ),
cardColor: _colorScheme.surface, cardColor: _colorScheme.surface,
); );

View File

@ -6,7 +6,7 @@ class Assets {
static const String _baseImagesPath = _basePath + '/images'; static const String _baseImagesPath = _basePath + '/images';
static const String _baseThemesPath = _basePath + '/images/themes'; static const String _baseThemesPath = _basePath + '/images/themes';
static const String _baseAnimationsPath = _basePath + '/animations'; static const String _baseAnimationsPath = _basePath + '/animations';
static const String _baseRecordsPath = _basePath + '/records'; static const String _baseRecordsPath = _basePath + '/images/records';
static String get verticalLogoWithText => static String get verticalLogoWithText =>
_baseImagesPath + '/logos/logo-vertical-$_themeSuffix.svg'; _baseImagesPath + '/logos/logo-vertical-$_themeSuffix.svg';

View File

@ -1,13 +1,7 @@
import 'package:just_audio/just_audio.dart'; import 'package:didvan/pages/home/chat/widgets/message_box.dart';
import 'package:record/record.dart';
import 'package:universal_html/js.dart' as js;
import 'package:didvan/config/theme_data.dart';
import 'package:didvan/constants/app_icons.dart';
import 'package:didvan/models/view/app_bar_data.dart'; import 'package:didvan/models/view/app_bar_data.dart';
import 'package:didvan/widgets/didvan/scaffold.dart'; import 'package:didvan/widgets/didvan/scaffold.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_vibrate/flutter_vibrate.dart';
class Chat extends StatelessWidget { class Chat extends StatelessWidget {
const Chat({Key? key}) : super(key: key); const Chat({Key? key}) : super(key: key);
@ -32,101 +26,13 @@ class Chat extends StatelessWidget {
), ),
), ),
Positioned( Positioned(
bottom: 0, bottom: MediaQuery.of(context).viewInsets.bottom,
right: 0, right: 0,
left: 0, left: 0,
child: Container( child: const MessageBox(),
height: 56,
decoration: BoxDecoration(
border: Border(
top: BorderSide(
color: Theme.of(context).colorScheme.cardBorder,
),
),
color: Theme.of(context).colorScheme.surface,
),
child: Row(
children: [
const _VoiceRecorderButton(),
Expanded(
child: TextField(
decoration: InputDecoration(
border: InputBorder.none,
hintText: 'بنویسید یا پیام صوتی بگذارید...',
hintStyle: Theme.of(context)
.textTheme
.caption!
.copyWith(
color: Theme.of(context).colorScheme.disabledText,
),
),
onChanged: (value) {},
),
),
],
),
),
), ),
], ],
), ),
); );
} }
} }
class _VoiceRecorderButton extends StatefulWidget {
const _VoiceRecorderButton({Key? key}) : super(key: key);
@override
_VoiceRecorderButtonState createState() => _VoiceRecorderButtonState();
}
class _VoiceRecorderButtonState extends State<_VoiceRecorderButton> {
final _recorder = Record();
final _player = AudioPlayer();
@override
void initState() {
_recorder.hasPermission();
super.initState();
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onLongPressStart: (details) async {
if (!kIsWeb) {
Vibrate.feedback(FeedbackType.medium);
}
await _recorder.start();
},
onLongPressEnd: (details) async {
final path = await _recorder.stop();
if (kIsWeb) {
await _player.setUrl(path!);
} else {
await _player.setFilePath(path!);
}
await _player.play();
if (kIsWeb) {
js.context.callMethod('playAudio', ['/dash.mp3']);
}
},
child: Container(
color: Colors.transparent,
height: double.infinity,
width: 52,
child: Icon(
DidvanIcons.mic_solid,
color: Theme.of(context).colorScheme.focusedBorder,
),
),
);
}
@override
void dispose() {
_recorder.dispose();
_player.dispose();
super.dispose();
}
}

View File

@ -1,3 +1,52 @@
import 'package:didvan/providers/core_provider.dart'; import 'dart:io';
class ChatState extends CoreProvier {} import 'package:didvan/providers/core_provider.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_vibrate/flutter_vibrate.dart';
import 'package:record/record.dart';
class ChatState extends CoreProvier {
final _recorder = Record();
File? recordedFile;
bool isRecording = false;
void deleteRecordedFile() {
recordedFile!.delete();
recordedFile = null;
notifyListeners();
}
Future<void> startRecording() async {
await _recorder.hasPermission();
if (!kIsWeb) {
Vibrate.feedback(FeedbackType.medium);
}
isRecording = true;
_recorder.start();
notifyListeners();
}
Future<void> stopRecording(bool sendImidiately) async {
final path = await _recorder.stop();
isRecording = false;
if (path == null) {
notifyListeners();
return;
}
if (kIsWeb) {
final uri = Uri.file(path);
recordedFile = File.fromUri(uri);
} else {
recordedFile = File(path);
}
if (sendImidiately) {
await sendMessage();
} else {
notifyListeners();
}
}
Future<void> sendMessage() async {}
}

View File

@ -0,0 +1,138 @@
import 'package:didvan/config/theme_data.dart';
import 'package:didvan/constants/app_icons.dart';
import 'package:didvan/pages/home/chat/chat_state.dart';
import 'package:didvan/widgets/audio_visualizer.dart';
import 'package:didvan/widgets/didvan/icon_button.dart';
import 'package:didvan/widgets/didvan/text.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class MessageBox extends StatelessWidget {
const MessageBox({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Container(
height: 56,
decoration: BoxDecoration(
border: Border(
top: BorderSide(
color: Theme.of(context).colorScheme.cardBorder,
),
),
color: Theme.of(context).colorScheme.surface,
),
child: Consumer<ChatState>(
builder: (context, state, child) {
if (state.isRecording) {
return const _Recording();
} else if (!state.isRecording && state.recordedFile != null) {
return const _RecordChecking();
}
return const _Typing();
},
),
);
}
}
class _Typing extends StatelessWidget {
const _Typing({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final state = context.read<ChatState>();
return Row(
children: [
DidvanIconButton(
icon: DidvanIcons.mic_solid,
onPressed: state.startRecording,
size: 32,
color: Theme.of(context).colorScheme.focusedBorder,
),
Expanded(
child: TextField(
textInputAction: TextInputAction.send,
decoration: InputDecoration(
border: InputBorder.none,
hintText: 'بنویسید یا پیام صوتی بگذارید...',
hintStyle: Theme.of(context)
.textTheme
.caption!
.copyWith(color: Theme.of(context).colorScheme.disabledText),
),
onChanged: (value) {},
),
),
],
);
}
}
class _Recording extends StatelessWidget {
const _Recording({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final state = context.read<ChatState>();
return Row(
children: [
GestureDetector(
onTap: () => state.stopRecording(true),
child: Container(
color: Colors.transparent,
height: double.infinity,
width: 52,
child: Icon(
DidvanIcons.send_solid,
color: Theme.of(context).colorScheme.focusedBorder,
),
),
),
Expanded(
child: DidvanText(
'در حال ضبط صدا ...',
style: Theme.of(context).textTheme.caption,
),
),
DidvanIconButton(
icon: DidvanIcons.stop_circle_solid,
color: Theme.of(context).colorScheme.secondary,
onPressed: () => state.stopRecording(false),
size: 32,
),
],
);
}
}
class _RecordChecking extends StatelessWidget {
const _RecordChecking({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final state = context.read<ChatState>();
return Row(
children: [
DidvanIconButton(
icon: DidvanIcons.send_solid,
onPressed: () => state.stopRecording(true),
color: Theme.of(context).colorScheme.focusedBorder,
),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: AudioVisualizer(
audioFile: state.recordedFile!,
),
),
),
DidvanIconButton(
icon: DidvanIcons.trash_solid,
color: Theme.of(context).colorScheme.secondary,
onPressed: state.deleteRecordedFile,
),
],
);
}
}

View File

@ -2,9 +2,12 @@ import 'package:didvan/models/settings_data.dart';
import 'package:didvan/services/storage/storage.dart'; import 'package:didvan/services/storage/storage.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hive_flutter/hive_flutter.dart'; import 'package:hive_flutter/hive_flutter.dart';
import 'package:path_provider/path_provider.dart';
class AppInitializer { class AppInitializer {
static Future<void> setupServices() async { static Future<void> setupServices() async {
StorageService.appDocsDir = (await getApplicationDocumentsDirectory()).path;
StorageService.appTempsDir = (await getTemporaryDirectory()).path;
await Hive.initFlutter(); await Hive.initFlutter();
} }

View File

@ -5,4 +5,21 @@ class DateTimeUtils {
hour: int.parse(input.split(':')[0]), hour: int.parse(input.split(':')[0]),
minute: int.parse(input.split(':')[1]), minute: int.parse(input.split(':')[1]),
); );
static String normalizeTimeDuration(Duration input) {
String minute;
String second;
if (input.inMinutes < 10) {
minute = '0${input.inMinutes}';
} else {
minute = input.inMinutes.toString();
}
int realSeconds = input.inSeconds % 60;
if (realSeconds < 10) {
second = '0$realSeconds';
} else {
second = (realSeconds).toString();
}
return '$minute:$second';
}
} }

View File

@ -0,0 +1,314 @@
import 'dart:io';
import 'dart:math';
import 'package:didvan/config/design_config.dart';
import 'package:didvan/config/theme_data.dart';
import 'package:didvan/constants/app_icons.dart';
import 'package:didvan/constants/assets.dart';
import 'package:didvan/services/storage/storage.dart';
import 'package:didvan/utils/date_time.dart';
import 'package:didvan/widgets/didvan/icon_button.dart';
import 'package:didvan/widgets/didvan/text.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:just_audio/just_audio.dart';
import 'package:just_waveform/just_waveform.dart';
class AudioVisualizer extends StatefulWidget {
final File audioFile;
const AudioVisualizer({
Key? key,
required this.audioFile,
}) : super(key: key);
@override
State<AudioVisualizer> createState() => _AudioVisualizerState();
}
class _AudioVisualizerState extends State<AudioVisualizer> {
final AudioPlayer _audioPlayer = AudioPlayer();
Stream<WaveformProgress>? waveDataStream;
@override
void initState() {
if (!kIsWeb) {
waveDataStream = JustWaveform.extract(
audioInFile: widget.audioFile,
waveOutFile: File(StorageService.appTempsDir + '/rec-wave.wave'),
zoom: const WaveformZoom.pixelsPerSecond(100),
);
}
_setupAudioPlayer();
super.initState();
}
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: Theme.of(context).brightness == Brightness.dark
? Theme.of(context).colorScheme.black
: Theme.of(context).colorScheme.background,
borderRadius: DesignConfig.mediumBorderRadius,
),
child: Row(
children: [
const SizedBox(width: 12),
StreamBuilder<Duration>(
stream: _audioPlayer.positionStream,
builder: (context, snapshot) {
String text = '';
if (_audioPlayer.duration == null) {
Future.delayed(Duration.zero, () {
setState(() {});
});
}
if (snapshot.data == null || snapshot.data == Duration.zero) {
text = DateTimeUtils.normalizeTimeDuration(
_audioPlayer.duration ?? Duration.zero);
} else {
text = DateTimeUtils.normalizeTimeDuration(snapshot.data!);
}
return DidvanText(
text,
color: Theme.of(context).colorScheme.focusedBorder,
isEnglishFont: true,
);
},
),
const SizedBox(width: 12),
Expanded(
child: Builder(
builder: (context) {
if (kIsWeb) {
return SvgPicture.asset(Assets.record);
}
return StreamBuilder<WaveformProgress>(
stream: waveDataStream,
builder: (context, snapshot) {
if (snapshot.data == null) {
return const SizedBox();
}
if (snapshot.data!.waveform == null) {
return const SizedBox();
}
final waveform = snapshot.data!.waveform!;
return GestureDetector(
onHorizontalDragUpdate: _changePosition,
onTapDown: _changePosition,
child: SizedBox(
height: double.infinity,
width: double.infinity,
child: _AudioWaveformWidget(
waveform: waveform,
audioPlayer: _audioPlayer,
start: Duration.zero,
scale: 2,
strokeWidth: 3,
duration: waveform.duration,
waveColor:
Theme.of(context).colorScheme.focusedBorder,
),
),
);
},
);
},
),
),
StreamBuilder<bool>(
stream: _audioPlayer.playingStream,
builder: (context, snapshot) {
return DidvanIconButton(
icon: snapshot.data == true
? DidvanIcons.pause_circle_solid
: DidvanIcons.play_circle_solid,
color: Theme.of(context).colorScheme.focusedBorder,
onPressed: _playAndPouse,
);
},
),
],
),
);
}
void _changePosition(details) {
double posper =
details.localPosition.dx / (MediaQuery.of(context).size.width - 200);
if (posper >= 1 || posper < 0) return;
final position = _audioPlayer.duration!.inMilliseconds;
_audioPlayer.seek(
Duration(milliseconds: (posper * position).toInt()),
);
setState(() {});
}
Future<void> _setupAudioPlayer() async {
if (kIsWeb) {
await _audioPlayer.setUrl(
widget.audioFile.uri.path.replaceAll('%3A', ':'),
);
} else {
await _audioPlayer.setFilePath(widget.audioFile.path);
}
}
Future<void> _playAndPouse() async {
if (_audioPlayer.playing) {
_audioPlayer.pause();
return;
}
await _audioPlayer.play();
}
@override
void dispose() {
_audioPlayer.dispose();
super.dispose();
}
}
class _AudioWaveformWidget extends StatefulWidget {
final Color waveColor;
final double scale;
final double strokeWidth;
final double pixelsPerStep;
final Waveform waveform;
final Duration start;
final Duration duration;
final AudioPlayer audioPlayer;
const _AudioWaveformWidget({
Key? key,
required this.waveform,
required this.start,
required this.duration,
required this.audioPlayer,
this.waveColor = Colors.blue,
this.scale = 1.0,
this.strokeWidth = 5.0,
this.pixelsPerStep = 8.0,
}) : super(key: key);
@override
__AudioWaveformWidgetState createState() => __AudioWaveformWidgetState();
}
class __AudioWaveformWidgetState extends State<_AudioWaveformWidget>
with SingleTickerProviderStateMixin {
double progress = 0;
@override
void initState() {
widget.audioPlayer.positionStream.listen((event) {
if (widget.audioPlayer.duration == null) return;
setState(() {
progress = event.inMilliseconds /
widget.audioPlayer.duration!.inMilliseconds *
100;
if (progress >= 100) {
progress = 0;
widget.audioPlayer.stop();
widget.audioPlayer.seek(Duration.zero);
}
});
});
super.initState();
}
@override
Widget build(BuildContext context) {
return ClipRect(
child: CustomPaint(
painter: _AudioWaveformPainter(
waveColor: widget.waveColor,
waveform: widget.waveform,
start: widget.start,
duration: widget.duration,
scale: widget.scale,
strokeWidth: widget.strokeWidth,
pixelsPerStep: widget.pixelsPerStep,
progressPercentage: progress,
progressColor: Theme.of(context).colorScheme.focusedBorder,
color: Theme.of(context).colorScheme.border,
),
),
);
}
}
class _AudioWaveformPainter extends CustomPainter {
final double scale;
final double strokeWidth;
final double pixelsPerStep;
final Waveform waveform;
final Duration start;
final Duration duration;
final double progressPercentage;
final Color progressColor;
final Color color;
_AudioWaveformPainter({
required this.waveform,
required this.start,
required this.duration,
required this.progressPercentage,
required this.color,
required this.progressColor,
Color waveColor = Colors.blue,
this.scale = 1.0,
this.strokeWidth = 5.0,
this.pixelsPerStep = 8.0,
});
@override
void paint(Canvas canvas, Size size) {
if (duration == Duration.zero) return;
double width = size.width;
double height = size.height;
final waveformPixelsPerWindow = waveform.positionToPixel(duration).toInt();
final waveformPixelsPerDevicePixel = waveformPixelsPerWindow / width;
final waveformPixelsPerStep = waveformPixelsPerDevicePixel * pixelsPerStep;
final sampleOffset = waveform.positionToPixel(start);
final sampleStart = -sampleOffset % waveformPixelsPerStep;
final totalLength = waveformPixelsPerWindow;
final wavePaintB = Paint()
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth
..strokeCap = StrokeCap.round
..color = progressColor;
final wavePaintA = Paint()
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth
..strokeCap = StrokeCap.round
..color = color;
for (var i = sampleStart.toDouble();
i <= waveformPixelsPerWindow + 1.0;
i += waveformPixelsPerStep) {
final sampleIdx = (sampleOffset + i).toInt();
final x = i / waveformPixelsPerDevicePixel;
final minY = normalise(waveform.getPixelMin(sampleIdx), height);
final maxY = normalise(waveform.getPixelMax(sampleIdx), height);
canvas.drawLine(
Offset(x + strokeWidth / 2, max(strokeWidth * 0.75, minY)),
Offset(x + strokeWidth / 2, min(height - strokeWidth * 0.75, maxY)),
i / totalLength < progressPercentage / 100 ? wavePaintB : wavePaintA,
);
}
}
@override
bool shouldRepaint(covariant _AudioWaveformPainter oldDelegate) {
return oldDelegate.progressPercentage != progressPercentage;
}
double normalise(int s, double height) {
final y = 32768 + (scale * s).clamp(-32768.0, 32767.0).toDouble();
return height - 1 - y * height / 65536;
}
}

View File

@ -0,0 +1,35 @@
import 'package:didvan/widgets/ink_wrapper.dart';
import 'package:flutter/material.dart';
class DidvanIconButton extends StatelessWidget {
final IconData icon;
final Color? color;
final double? size;
final double? gestureSize;
final VoidCallback onPressed;
const DidvanIconButton({
Key? key,
required this.icon,
required this.onPressed,
this.color,
this.size,
this.gestureSize,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return InkWrapper(
onPressed: onPressed,
borderRadius: BorderRadius.circular(200),
child: SizedBox(
height: gestureSize ?? 48,
width: gestureSize ?? 48,
child: Icon(
icon,
size: size,
color: color,
),
),
);
}
}

View File

@ -301,6 +301,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.4.2" version: "0.4.2"
just_waveform:
dependency: "direct main"
description:
name: just_waveform
url: "https://pub.dartlang.org"
source: hosted
version: "0.0.1"
lints: lints:
dependency: transitive dependency: transitive
description: description:

View File

@ -56,6 +56,7 @@ dependencies:
record: ^3.0.2 record: ^3.0.2
just_audio: ^0.9.18 just_audio: ^0.9.18
record_web: ^0.2.1 record_web: ^0.2.1
just_waveform: ^0.0.1
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: