diff --git a/lib/config/theme_data.dart b/lib/config/theme_data.dart index 70fb3cc..b1a3388 100644 --- a/lib/config/theme_data.dart +++ b/lib/config/theme_data.dart @@ -82,8 +82,8 @@ class DarkThemeConfig { colorScheme: _colorScheme, fontFamily: 'Dana-FA', textTheme: _textTheme, - iconTheme: const IconThemeData( - color: text, + iconTheme: IconThemeData( + color: _colorScheme.focusedBorder, ), cardColor: _colorScheme.surface, ); diff --git a/lib/constants/assets.dart b/lib/constants/assets.dart index 66bffde..27b28bc 100644 --- a/lib/constants/assets.dart +++ b/lib/constants/assets.dart @@ -6,7 +6,7 @@ class Assets { static const String _baseImagesPath = _basePath + '/images'; static const String _baseThemesPath = _basePath + '/images/themes'; static const String _baseAnimationsPath = _basePath + '/animations'; - static const String _baseRecordsPath = _basePath + '/records'; + static const String _baseRecordsPath = _basePath + '/images/records'; static String get verticalLogoWithText => _baseImagesPath + '/logos/logo-vertical-$_themeSuffix.svg'; diff --git a/lib/pages/home/chat/chat.dart b/lib/pages/home/chat/chat.dart index 45e78f4..4d07d2e 100644 --- a/lib/pages/home/chat/chat.dart +++ b/lib/pages/home/chat/chat.dart @@ -1,13 +1,7 @@ -import 'package:just_audio/just_audio.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/pages/home/chat/widgets/message_box.dart'; import 'package:didvan/models/view/app_bar_data.dart'; import 'package:didvan/widgets/didvan/scaffold.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_vibrate/flutter_vibrate.dart'; class Chat extends StatelessWidget { const Chat({Key? key}) : super(key: key); @@ -32,101 +26,13 @@ class Chat extends StatelessWidget { ), ), Positioned( - bottom: 0, + bottom: MediaQuery.of(context).viewInsets.bottom, right: 0, left: 0, - child: Container( - 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) {}, - ), - ), - ], - ), - ), + child: const MessageBox(), ), ], ), ); } } - -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(); - } -} diff --git a/lib/pages/home/chat/chat_state.dart b/lib/pages/home/chat/chat_state.dart index 19ff935..d841aac 100644 --- a/lib/pages/home/chat/chat_state.dart +++ b/lib/pages/home/chat/chat_state.dart @@ -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 startRecording() async { + await _recorder.hasPermission(); + if (!kIsWeb) { + Vibrate.feedback(FeedbackType.medium); + } + isRecording = true; + _recorder.start(); + notifyListeners(); + } + + Future 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 sendMessage() async {} +} diff --git a/lib/pages/home/chat/widgets/message_box.dart b/lib/pages/home/chat/widgets/message_box.dart new file mode 100644 index 0000000..6c2ac9b --- /dev/null +++ b/lib/pages/home/chat/widgets/message_box.dart @@ -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( + 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(); + 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(); + 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(); + 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, + ), + ], + ); + } +} diff --git a/lib/services/app_initalizer.dart b/lib/services/app_initalizer.dart index 5dfe216..0b948e5 100644 --- a/lib/services/app_initalizer.dart +++ b/lib/services/app_initalizer.dart @@ -2,9 +2,12 @@ import 'package:didvan/models/settings_data.dart'; import 'package:didvan/services/storage/storage.dart'; import 'package:flutter/material.dart'; import 'package:hive_flutter/hive_flutter.dart'; +import 'package:path_provider/path_provider.dart'; class AppInitializer { static Future setupServices() async { + StorageService.appDocsDir = (await getApplicationDocumentsDirectory()).path; + StorageService.appTempsDir = (await getTemporaryDirectory()).path; await Hive.initFlutter(); } diff --git a/lib/utils/date_time.dart b/lib/utils/date_time.dart index f36466d..c603d1a 100644 --- a/lib/utils/date_time.dart +++ b/lib/utils/date_time.dart @@ -5,4 +5,21 @@ class DateTimeUtils { hour: int.parse(input.split(':')[0]), 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'; + } } diff --git a/lib/widgets/audio_visualizer.dart b/lib/widgets/audio_visualizer.dart new file mode 100644 index 0000000..f4fdc3d --- /dev/null +++ b/lib/widgets/audio_visualizer.dart @@ -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 createState() => _AudioVisualizerState(); +} + +class _AudioVisualizerState extends State { + final AudioPlayer _audioPlayer = AudioPlayer(); + + Stream? 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( + 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( + 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( + 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 _setupAudioPlayer() async { + if (kIsWeb) { + await _audioPlayer.setUrl( + widget.audioFile.uri.path.replaceAll('%3A', ':'), + ); + } else { + await _audioPlayer.setFilePath(widget.audioFile.path); + } + } + + Future _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; + } +} diff --git a/lib/widgets/didvan/icon_button.dart b/lib/widgets/didvan/icon_button.dart new file mode 100644 index 0000000..6f12245 --- /dev/null +++ b/lib/widgets/didvan/icon_button.dart @@ -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, + ), + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 2a8196e..bd7794e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -301,6 +301,13 @@ packages: url: "https://pub.dartlang.org" source: hosted 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: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 748390f..ac58b10 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -56,6 +56,7 @@ dependencies: record: ^3.0.2 just_audio: ^0.9.18 record_web: ^0.2.1 + just_waveform: ^0.0.1 dev_dependencies: flutter_test: