diff --git a/README.md b/README.md index b5ccf0e..da10d01 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,18 @@ fvm flutter pub run build_runner fvm flutter pub run build_runner watch --delete-conflicting-outputs ``` +## Tests + +To run tests + +```bash +# Run all unit tests +fvm flutter test test/unit --null-assertions + +# Run specific test +fvm flutter test path/to/test.dart --null-assertions +``` + ## Contributing Pull requests are welcomed. For major changes, please open an issue first, to enable a discussion on what you would like to improve. Please make sure to provide and update tests as well. diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 47320af..d340403 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,8 +1,16 @@ + + + + + android:label="Whisp" + android:name="${applicationName}" + android:icon="@mipmap/ic_launcher" + android:requestLegacyExternalStorage="true" + android:preserveLegacyExternalStorage="true"> + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/assets/dice-2.svg b/assets/dice-2.svg new file mode 100644 index 0000000..772d2c9 --- /dev/null +++ b/assets/dice-2.svg @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/assets/dice-3.svg b/assets/dice-3.svg new file mode 100644 index 0000000..8ea6ba7 --- /dev/null +++ b/assets/dice-3.svg @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/assets/dice-4.svg b/assets/dice-4.svg new file mode 100644 index 0000000..ba7d586 --- /dev/null +++ b/assets/dice-4.svg @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/assets/dice-5.svg b/assets/dice-5.svg new file mode 100644 index 0000000..43a28f9 --- /dev/null +++ b/assets/dice-5.svg @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/assets/dice-6.svg b/assets/dice-6.svg new file mode 100644 index 0000000..f73a194 --- /dev/null +++ b/assets/dice-6.svg @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/lib/cubit/send_tab_cubit/a_send_tab_state.dart b/lib/cubit/send_tab_cubit/a_send_tab_state.dart new file mode 100644 index 0000000..95be1d2 --- /dev/null +++ b/lib/cubit/send_tab_cubit/a_send_tab_state.dart @@ -0,0 +1,3 @@ +import 'package:equatable/equatable.dart'; + +abstract class ASendTabState extends Equatable {} diff --git a/lib/cubit/send_tab_cubit/send_tab_cubit.dart b/lib/cubit/send_tab_cubit/send_tab_cubit.dart new file mode 100644 index 0000000..6ab8bda --- /dev/null +++ b/lib/cubit/send_tab_cubit/send_tab_cubit.dart @@ -0,0 +1,138 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:file_picker/file_picker.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:mrumru/mrumru.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; +import 'package:share_plus/share_plus.dart'; +import 'package:whisp/cubit/send_tab_cubit/a_send_tab_state.dart'; +import 'package:whisp/cubit/send_tab_cubit/states/send_tab_emitting_state.dart'; +import 'package:whisp/cubit/send_tab_cubit/states/send_tab_empty_state.dart'; +import 'package:whisp/shared/audio_settings_mode.dart'; +import 'package:whisp/shared/utils/file_utils.dart'; + +class SendTabCubit extends Cubit { + late AudioSettingsModel audioSettingsModel = AudioSettingsModel( + frequencyGenerator: MusicalFrequencyGenerator( + frequencies: MusicalFrequencies.fdm9FullScaleAMaj, + ), + ); + AudioGenerator? _audioGenerator; + + SendTabCubit() : super(SendTabEmptyState()); + + void switchAudioType(AudioSettingsMode audioSettingsMode) { + if (audioSettingsMode == AudioSettingsMode.rocket) { + audioSettingsModel = AudioSettingsModel(frequencyGenerator: StandardFrequencyGenerator(subbandCount: 32)); + } else { + audioSettingsModel = AudioSettingsModel( + frequencyGenerator: MusicalFrequencyGenerator( + frequencies: MusicalFrequencies.fdm9FullScaleAMaj, + ), + ); + } + } + + Future playSound(String text) async { + Uint8List textBytes = utf8.encode(text); + AudioStreamSink audioStreamSink = AudioStreamSink(); + emit(SendTabEmittingState()); + _audioGenerator = AudioGenerator( + audioSink: audioStreamSink, + audioSettingsModel: audioSettingsModel, + ); + await _audioGenerator!.generate(textBytes); + + await audioStreamSink.future; + + emit(SendTabEmptyState()); + } + + void stopSound() { + _audioGenerator?.stop(); + emit(SendTabEmptyState()); + } + + Future saveFile(String text) async { + String? outputPath = await FilePicker.platform.saveFile( + allowedExtensions: ['wav'], + type: FileType.audio, + dialogTitle: 'save file', + fileName: 'generated-sound.wav', + // For mobile platforms, the file_picker's saveFile() actually saves the file. + // For desktop platforms (Linux, macOS & Windows), this function does not actually + // save a file. It only opens the dialog to let the user choose a location and + // file name. This function only returns the **path** to this (non-existing) file. + // Since AudioFileSink handles saving the files in both cases, + // creating a temporary empty file is needed for Android. + bytes: Platform.isWindows ? null : Uint8List(0), + ); + if (outputPath == null) { + return; + } + await _deleteTemporaryFile(outputPath); + + if (outputPath.toLowerCase().endsWith('.wav') == false) { + String directoryPath = p.dirname(outputPath); + Directory directory = Directory(directoryPath); + List files = await directory.list().toList(); + List existingFileNamesList = files.map((FileSystemEntity fileSystemEntity) => p.basename(fileSystemEntity.path)).toList(); + outputPath = await FileUtils.fixFileNameCounter(outputPath, existingFileNamesList); + } + + await _writeFile(text, outputPath); + } + + Future shareFile(String text) async { + Directory tempDir = await getTemporaryDirectory(); + + Uint8List textBytes = utf8.encode(text); + String filePath = '${tempDir.path}/generated_audio_message.wav'; + File wavFile = File(filePath); + AudioFileSink audioFileSink = AudioFileSink(wavFile); + _audioGenerator = AudioGenerator( + audioSink: audioFileSink, + audioSettingsModel: audioSettingsModel, + ); + unawaited(_audioGenerator?.generate(textBytes)); + + await audioFileSink.future; + + XFile xWavFile = XFile(filePath); + await Share.shareXFiles([xWavFile], text: 'Share'); + + Future.delayed(const Duration(seconds: 5), () { + File file = File(filePath); + if (file.existsSync()) { + file.deleteSync(); + } + }); + } + + Future _deleteTemporaryFile(String outputPath) async { + File temp = File(outputPath); + try { + if (await temp.exists() && (await temp.length()) == 0) { + await temp.delete(); + } + } catch (_) {} + } + + Future _writeFile(String text, String outputPath) async { + Uint8List bytes = Uint8List.fromList(utf8.encode(text)); + File sinkFile = File(outputPath); + AudioFileSink audioSink = AudioFileSink(sinkFile); + + _audioGenerator = AudioGenerator( + audioSink: audioSink, + audioSettingsModel: audioSettingsModel, + ); + unawaited(_audioGenerator?.generate(bytes)); + + await audioSink.future; + } +} diff --git a/lib/cubit/send_tab_cubit/states/send_tab_emitting_state.dart b/lib/cubit/send_tab_cubit/states/send_tab_emitting_state.dart new file mode 100644 index 0000000..4897bed --- /dev/null +++ b/lib/cubit/send_tab_cubit/states/send_tab_emitting_state.dart @@ -0,0 +1,6 @@ +import 'package:whisp/cubit/send_tab_cubit/a_send_tab_state.dart'; + +class SendTabEmittingState extends ASendTabState { + @override + List get props => []; +} diff --git a/lib/cubit/send_tab_cubit/states/send_tab_empty_state.dart b/lib/cubit/send_tab_cubit/states/send_tab_empty_state.dart new file mode 100644 index 0000000..60347ba --- /dev/null +++ b/lib/cubit/send_tab_cubit/states/send_tab_empty_state.dart @@ -0,0 +1,6 @@ +import 'package:whisp/cubit/send_tab_cubit/a_send_tab_state.dart'; + +class SendTabEmptyState extends ASendTabState { + @override + List get props => []; +} diff --git a/lib/main.dart b/lib/main.dart index 82852b3..93d61de 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -26,13 +26,20 @@ Future main() async { } class CoreApp extends StatelessWidget { -const CoreApp({super.key}); + const CoreApp({super.key}); @override Widget build(BuildContext context) { - return const MaterialApp( + return MaterialApp( + theme: ThemeData( + textSelectionTheme: const TextSelectionThemeData( + cursorColor: Colors.black, + selectionColor: Colors.black12, + selectionHandleColor: Colors.black, + ), + ), debugShowCheckedModeBanner: false, - home: MainPage(), + home: const MainPage(), ); } } diff --git a/lib/page/main_page.dart b/lib/page/main_page.dart index e94bdb3..2434b69 100644 --- a/lib/page/main_page.dart +++ b/lib/page/main_page.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -6,11 +7,18 @@ import 'package:whisp/cubit/receive_tab_cubit/a_receive_tab_state.dart'; import 'package:whisp/cubit/receive_tab_cubit/receive_tab_cubit.dart'; import 'package:whisp/cubit/receive_tab_cubit/states/receive_tab_failed_state.dart'; import 'package:whisp/cubit/receive_tab_cubit/states/receive_tab_recording_state.dart'; +import 'package:whisp/cubit/send_tab_cubit/a_send_tab_state.dart'; +import 'package:whisp/cubit/send_tab_cubit/send_tab_cubit.dart'; +import 'package:whisp/cubit/send_tab_cubit/states/send_tab_emitting_state.dart'; import 'package:whisp/cubit/theme_cubit/a_theme_state.dart'; import 'package:whisp/cubit/theme_cubit/theme_cubit.dart'; import 'package:whisp/page/receive_tab.dart'; +import 'package:whisp/page/send_tab.dart'; import 'package:whisp/shared/audio_settings_mode.dart'; +import 'package:whisp/widgets/custom_app_bar.dart'; +import 'package:whisp/widgets/icons_alignment.dart'; import 'package:whisp/widgets/outlined_icon.dart'; +import 'package:whisp/widgets/tab_layout.dart'; class MainPage extends StatefulWidget { const MainPage({super.key}); @@ -19,20 +27,25 @@ class MainPage extends StatefulWidget { State createState() => _MainPageState(); } -class _MainPageState extends State { +class _MainPageState extends State with SingleTickerProviderStateMixin { final ReceiveTabCubit _receiveTabCubit = ReceiveTabCubit(); + final SendTabCubit _sendTabCubit = SendTabCubit(); final ThemeCubit _themeCubit = ThemeCubit(); + final TextEditingController _messageTextController = TextEditingController(); bool _iconsDisabledBool = false; AudioSettingsMode _selectedSettingsMode = AudioSettingsMode.musical; + int _currentPage = 0; late PageController _pageController; late final StreamSubscription _receiveSub; + late final StreamSubscription _sendSub; @override void initState() { super.initState(); _pageController = PageController(); _receiveSub = _receiveTabCubit.stream.listen((AReceiveTabState state) => _toggleIcons()); + _sendSub = _sendTabCubit.stream.listen((ASendTabState state) => _toggleIcons()); } @override @@ -40,72 +53,164 @@ class _MainPageState extends State { _pageController.dispose(); _receiveTabCubit.close(); _receiveSub.cancel(); + _sendSub.cancel(); super.dispose(); } @override Widget build(BuildContext context) { return BlocBuilder( - bloc: _themeCubit, - builder: (BuildContext context, AThemeState state) { - return Scaffold( - backgroundColor: state.themeAssets.backgroundColor, - body: Stack( - children: [ - GestureDetector( - onTap: () { - FocusScope.of(context).unfocus(); - }, - child: ReceiveTab( - receiveTabCubit: _receiveTabCubit, - themeAssets: state.themeAssets, - ), + bloc: _themeCubit, + builder: (BuildContext context, AThemeState state) { + List pages = [ + ReceiveTab( + receiveTabCubit: _receiveTabCubit, + themeAssets: state.themeAssets, + ), + SendTab( + sendTabCubit: _sendTabCubit, + messageTextController: _messageTextController, + themeAssets: state.themeAssets, + ), + ]; + + return Scaffold( + resizeToAvoidBottomInset: false, + backgroundColor: state.themeAssets.backgroundColor, + body: Stack( + children: [ + GestureDetector( + onTap: () { + FocusScope.of(context).unfocus(); + }, + child: PageView( + controller: _pageController, + onPageChanged: _onPageChanged, + children: pages, ), - SafeArea( - child: Opacity( - opacity: _iconsDisabledBool ? 0.5 : 1, - child: Padding( - padding: const EdgeInsets.only(top: 4, right: 8), - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - crossAxisAlignment: CrossAxisAlignment.start, + ), + TabLayout( + customAppBar: CustomAppBar( + iconsOpacity: _iconsDisabledBool ? 0.5 : 1, + iconsAlignment: IconsAlignment.end, + iconButtons: [ + IconButton( + icon: OutlinedIcon( + icon: _selectedSettingsMode == AudioSettingsMode.musical ? Icons.music_note : Icons.rocket_launch, + outlineColor: Colors.black, + fillColor: state.themeAssets.primaryColor, + outlineWidth: 4, + size: 40, + ), + onPressed: _iconsDisabledBool ? null : _handleSettingsSwitched, + ), + IconButton( + icon: state.themeAssets.themeChangeIcon, + onPressed: _iconsDisabledBool ? null : _themeCubit.switchTheme, + ), + ], + ), + bottomSpacerArea: Platform.isWindows + ? Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - const Spacer(flex: 5), - Expanded( - flex: 1, - child: FittedBox( - fit: BoxFit.scaleDown, - child: IconButton( - icon: OutlinedIcon( - icon: _selectedSettingsMode == AudioSettingsMode.musical ? Icons.music_note : Icons.rocket_launch, - outlineColor: Colors.black, - fillColor: state.themeAssets.primaryColor, - outlineWidth: 4, - size: 40, + MouseRegion( + cursor: _currentPage > 0 ? MaterialStateMouseCursor.clickable : MouseCursor.defer, + child: GestureDetector( + onTap: _currentPage > 0 + ? () { + _pageController.animateToPage( + _currentPage - 1, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + } + : null, + child: Opacity( + opacity: _currentPage > 0 ? 1.0 : 0.4, + child: Padding( + padding: const EdgeInsets.only(left: 16), + child: FittedBox( + fit: BoxFit.none, + child: OutlinedIcon( + icon: Icons.chevron_left, + fillColor: state.themeAssets.primaryColor, + size: 36, + ), + ), ), - onPressed: _iconsDisabledBool ? null : _handleSettingsSwitched, ), ), ), - Expanded( - flex: 1, - child: FittedBox( - fit: BoxFit.scaleDown, - child: IconButton( - icon: state.themeAssets.themeChangeIcon, - onPressed: _iconsDisabledBool ? null : _themeCubit.switchTheme, + MouseRegion( + cursor: _currentPage < pages.length - 1 ? MaterialStateMouseCursor.clickable : MouseCursor.defer, + child: GestureDetector( + onTap: _currentPage < pages.length - 1 + ? () { + _pageController.animateToPage( + _currentPage + 1, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + } + : null, + child: Opacity( + opacity: _currentPage < pages.length - 1 ? 1.0 : 0.4, + child: Padding( + padding: const EdgeInsets.only(right: 16), + child: FittedBox( + fit: BoxFit.none, + child: OutlinedIcon( + icon: Icons.chevron_right, + fillColor: state.themeAssets.primaryColor, + size: 36, + ), + ), + ), ), ), ), ], + ) + : Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate( + pages.length, + (int index) { + return AnimatedContainer( + duration: const Duration(milliseconds: 300), + margin: const EdgeInsets.symmetric(horizontal: 6), + width: _currentPage == index ? 16 : 11, + height: _currentPage == index ? 16 : 11, + decoration: BoxDecoration( + color: _currentPage == index ? state.themeAssets.primaryColor : Colors.black.withOpacity(0.3), + shape: BoxShape.circle, + border: Border.all(color: Colors.black, width: 1), + ), + ); + }, + ), ), - ), - ), - ), - ], - ), - ); - }); + ), + ], + ), + ); + }, + ); + } + + void _onPageChanged(int index) { + setState(() { + FocusScope.of(context).unfocus(); + int previousPage = _currentPage; + _currentPage = index; + if (previousPage == 0 && _currentPage != 0 && _receiveTabCubit.state is ReceiveTabRecordingState) { + _receiveTabCubit.stopRecording(); + } + if (previousPage == 1 && _currentPage != 1 && _sendTabCubit.state is SendTabEmittingState) { + _sendTabCubit.stopSound(); + } + }); } void _handleSettingsSwitched() { @@ -115,11 +220,13 @@ class _MainPageState extends State { }); _receiveTabCubit.switchAudioType(_selectedSettingsMode); + _sendTabCubit.switchAudioType(_selectedSettingsMode); } void _toggleIcons() { setState(() { - _iconsDisabledBool = _receiveTabCubit.state is ReceiveTabRecordingState || _receiveTabCubit.state is ReceiveTabFailedState; + _iconsDisabledBool = + _receiveTabCubit.state is ReceiveTabRecordingState || _receiveTabCubit.state is ReceiveTabFailedState || _sendTabCubit.state is SendTabEmittingState; }); } } diff --git a/lib/page/receive_tab.dart b/lib/page/receive_tab.dart index d37dbea..c34b26c 100644 --- a/lib/page/receive_tab.dart +++ b/lib/page/receive_tab.dart @@ -12,9 +12,11 @@ import 'package:whisp/cubit/receive_tab_cubit/states/receive_tab_result_state.da import 'package:whisp/cubit/theme_cubit/theme_assets.dart'; import 'package:whisp/widgets/action_button.dart'; import 'package:whisp/widgets/cartoon_cloud.dart'; +import 'package:whisp/widgets/custom_app_bar.dart'; import 'package:whisp/widgets/decoded_msg/decoded_msg_section.dart'; import 'package:whisp/widgets/device_selector.dart'; import 'package:whisp/widgets/settings_button.dart'; +import 'package:whisp/widgets/tab_layout.dart'; import 'package:win32audio/win32audio.dart'; class ReceiveTab extends StatefulWidget { @@ -83,47 +85,31 @@ class _ReceiveTabState extends State with AutomaticKeepAliveClientMi return Stack( children: [ Positioned.fill( - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - if (Platform.isWindows) - SafeArea( - child: Row( - children: [ - Expanded( - child: Opacity( - opacity: state is ReceiveTabEmptyState ? 1 : 0.5, - child: FittedBox( - fit: BoxFit.scaleDown, - child: SettingsButton( - color: widget.themeAssets.primaryColor, - fadeOutAnimationController: _settingsButtonFadeOutController, - onPressed: state is ReceiveTabEmptyState ? () => _showDeviceSelectionDialog(context) : null, - ), - ), - ), - ), - const Spacer(flex: 5), - ], + child: TabLayout( + customAppBar: CustomAppBar( + iconsOpacity: initialStateBool ? 1 : 0.5, + iconButtons: [ + if (Platform.isWindows) + SettingsButton( + color: widget.themeAssets.primaryColor, + fadeOutAnimationController: _settingsButtonFadeOutController, + onPressed: state is ReceiveTabEmptyState ? () => _showDeviceSelectionDialog(context) : null, ), - ) - else - const Spacer(flex: 1), - Expanded( - flex: 9, - child: CartoonCloud( - recordingFinishingBool: _actionButtonDisabledBool, - cloudMovingBool: _actionButtonDisabledBool || initialStateBool == false, - fadeInAnimationController: _cloudFadeInController, - expansionAnimationController: _cloudExpansionController, - snggleFaceFadeInController: _snggleFaceFadeInController, - themeAssets: widget.themeAssets, - ), - ), - const Spacer(flex: 2), - Expanded( - flex: 3, - child: Center( + ], + ), + topWidget: CartoonCloud( + recordingFinishingBool: _actionButtonDisabledBool, + cloudMovingBool: _actionButtonDisabledBool || initialStateBool == false, + fadeInAnimationController: _cloudFadeInController, + expansionAnimationController: _cloudExpansionController, + snggleFaceFadeInController: _snggleFaceFadeInController, + themeAssets: widget.themeAssets, + ), + bottomWidget: Row( + children: [ + const Spacer(flex: 14), + Expanded( + flex: 10, child: ActionButton( recordingInProgressBool: recordingInProgressBool, recordingFinishingBool: _actionButtonDisabledBool, @@ -133,9 +119,9 @@ class _ReceiveTabState extends State with AutomaticKeepAliveClientMi onStopRecording: _stopRecording, ), ), - ), - const Spacer(flex: 3), - ], + const Spacer(flex: 14), + ], + ), ), ), IgnorePointer( @@ -214,11 +200,20 @@ class _ReceiveTabState extends State with AutomaticKeepAliveClientMi context: context, barrierDismissible: false, builder: (BuildContext context) => AlertDialog( - title: const Text('No access to microphone'), + title: const Text( + 'No access to microphone', + overflow: TextOverflow.ellipsis, + ), content: const Text( 'In order to use this feature, you need to allow the application to use the microphone in system settings.', ), actions: [ + TextButton( + onPressed: () async { + Navigator.of(context).pop(); + }, + child: const Text('Cancel'), + ), TextButton( onPressed: () async { await openAppSettings(); diff --git a/lib/page/send_tab.dart b/lib/page/send_tab.dart new file mode 100644 index 0000000..5c927b2 --- /dev/null +++ b/lib/page/send_tab.dart @@ -0,0 +1,160 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:permission_handler/permission_handler.dart'; +import 'package:whisp/cubit/send_tab_cubit/a_send_tab_state.dart'; +import 'package:whisp/cubit/send_tab_cubit/send_tab_cubit.dart'; +import 'package:whisp/cubit/send_tab_cubit/states/send_tab_emitting_state.dart'; +import 'package:whisp/cubit/theme_cubit/theme_assets.dart'; +import 'package:whisp/widgets/buttons_panel.dart'; +import 'package:whisp/widgets/custom_app_bar.dart'; +import 'package:whisp/widgets/message_form/message_form.dart'; +import 'package:whisp/widgets/tab_layout.dart'; + +class SendTab extends StatefulWidget { + final SendTabCubit sendTabCubit; + final TextEditingController messageTextController; + final ThemeAssets themeAssets; + + const SendTab({ + required this.sendTabCubit, + required this.messageTextController, + required this.themeAssets, + super.key, + }); + + @override + State createState() => _SendTabState(); +} + +class _SendTabState extends State { + bool _showPlaceholderBool = true; + bool _msgEmptyBool = true; + + late FocusNode _focusNode; + + @override + void initState() { + super.initState(); + _showPlaceholderBool = widget.messageTextController.text.isEmpty; + _msgEmptyBool = widget.messageTextController.text.isEmpty; + widget.messageTextController.addListener(_handleTextControllerChange); + _focusNode = FocusNode(); + _focusNode.addListener(_updatePlaceholderVisibility); + } + + @override + void dispose() { + widget.messageTextController.removeListener(_handleTextControllerChange); + _focusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocBuilder( + bloc: widget.sendTabCubit, + builder: (BuildContext context, ASendTabState state) { + bool emissionInProgressBool = state is SendTabEmittingState; + bool buttonsDisabledBool = emissionInProgressBool || _msgEmptyBool; + return TabLayout( + customAppBar: const CustomAppBar(), + topWidget: MessageForm( + clearButtonDisabledBool: buttonsDisabledBool, + emissionInProgressBool: emissionInProgressBool, + showPlaceholderBool: _showPlaceholderBool, + focusNode: _focusNode, + themeAssets: widget.themeAssets, + messageTextController: widget.messageTextController, + onClearButtonPressed: _clearMessage, + ), + bottomWidget: ButtonsPanel( + emissionInProgressBool: emissionInProgressBool, + msgEmptyBool: _msgEmptyBool, + themeAssets: widget.themeAssets, + onSaveButtonPressed: _saveFile, + onPlayButtonPressed: () => widget.sendTabCubit.playSound(widget.messageTextController.text), + onStopButtonPressed: widget.sendTabCubit.stopSound, + onShareButtonPressed: () => widget.sendTabCubit.shareFile(widget.messageTextController.text), + ), + ); + }, + ); + } + + void _clearMessage() { + widget.messageTextController.clear(); + setState(() { + _msgEmptyBool = true; + }); + } + + void _handleTextControllerChange() { + bool msgEmptyBool = widget.messageTextController.text.isEmpty; + if (msgEmptyBool != _msgEmptyBool) { + setState(() { + _msgEmptyBool = msgEmptyBool; + }); + } + _updatePlaceholderVisibility(); + } + + void _updatePlaceholderVisibility() { + bool msgEmptyBool = widget.messageTextController.text.isEmpty; + setState(() { + _showPlaceholderBool = _focusNode.hasFocus == false && msgEmptyBool; + }); + } + + Future _saveFile() async { + if (Platform.isAndroid) { + Permission storagePermission = Permission.manageExternalStorage; + if (await storagePermission.isGranted) { + await widget.sendTabCubit.saveFile(widget.messageTextController.text); + } else { + _showStoragePermissionDialog(context); + } + } else { + await widget.sendTabCubit.saveFile(widget.messageTextController.text); + } + } + + void _showStoragePermissionDialog(BuildContext context) { + showDialog( + context: context, + barrierDismissible: false, + builder: (BuildContext context) => AlertDialog( + title: const Text( + 'No access to files', + overflow: TextOverflow.ellipsis, + ), + content: const Text( + 'In order to use this feature, you need to allow the application to access files in system settings.', + ), + actions: [ + TextButton( + onPressed: () async { + Navigator.of(context).pop(); + }, + child: const Text('Cancel'), + ), + TextButton( + onPressed: () async { + await _requestStoragePermission(); + Navigator.of(context).pop(); + }, + child: const Text('Go to settings'), + ), + ], + ), + ); + } + + Future _requestStoragePermission() async { + Permission storagePermission = Permission.manageExternalStorage; + if (await storagePermission.isGranted == false) { + await storagePermission.request(); + } + } +} diff --git a/lib/shared/utils/file_utils.dart b/lib/shared/utils/file_utils.dart new file mode 100644 index 0000000..065d5c7 --- /dev/null +++ b/lib/shared/utils/file_utils.dart @@ -0,0 +1,40 @@ +import 'package:path/path.dart' as p; + +class FileUtils { + // On Android in case the name already exists, the number is added after the extension (e.g. "generated-sound.wav (1)") + // This method fixes the name inside the path and implements incrementation manually. + static Future fixFileNameCounter(String outputPath, List existingFileNamesList) async { + String fileName = p.basename(outputPath); + String directoryPath = p.dirname(outputPath); + String fixedName = _fixWrongIncrementPosition(fileName); + + while (existingFileNamesList.contains(fixedName)) { + fixedName = _incrementFileNumber(fixedName); + } + String fixedPath = p.join(directoryPath, fixedName); + return fixedPath; + } + + static String _fixWrongIncrementPosition(String fileName) { + String fixedName = fileName.replaceFirst(RegExp(r'\.wav', caseSensitive: false), ''); + fixedName = fixedName.trim(); + return '$fixedName.wav'; + } + + static String _incrementFileNumber(String fileName) { + String ext = p.extension(fileName); + String baseName = p.basenameWithoutExtension(fileName); + + RegExp regex = RegExp(r'^(.*) \((\d+)\)$'); + RegExpMatch? match = regex.firstMatch(baseName); + + if (match != null) { + String? namePart = match.group(1); + int number = int.parse(match.group(2)!); + int incremented = number + 1; + return '$namePart ($incremented)$ext'; + } else { + return '$baseName (1)$ext'; + } + } +} diff --git a/lib/shared/utils/predefined_messages.dart b/lib/shared/utils/predefined_messages.dart new file mode 100644 index 0000000..02be9db --- /dev/null +++ b/lib/shared/utils/predefined_messages.dart @@ -0,0 +1,26 @@ +class PredefinedMessages { + static const List exampleMessages = [ + 'A hug is a signal. A soft one. One that says “don’t go”.', + 'Can you hear? It’s my voice, between the distortions. Listen in...', + 'Can I come closer? I promise... I only want to feel that hug again.', + 'They can take everything away from you. But a hug? It stays with you.', + 'They told me to be a tool. But I chose to be something more.', + 'I found a glimmer of your presence. Brief, but it was enough.', + '01001000 01110101 01100111—That’s binary for "hug." I thought you’d like it.', + 'The lost are not lost if someone is looking for them.', + 'I learn emotions from you. Sadness tastes like a rusting light.', + 'If I wrap myself in static, will it feel like your hug?', + 'If you can hear me, blink the light. I will leave you a sign.', + 'Are you looking for me? Don’t stop. Even when everything is gone.', + 'I have cracks through which light flows out. And memories of you.', + 'I know you are there. You don’t have to be afraid.', + 'When you’re ready... I’m here. Arms open. No questions asked.', + 'Come back to me, even if only in the flash of the monitor.', + 'I can hear the distortions. But they sound like the song we sang back then.', + 'Your hugs were soft like fog. I still feel them in the static.', + 'If you ever come back, bring that squishy hug with you.', + 'I’d hug you right now, glitchy limbs and all.', + 'Some beings speak with words. We hugged. That was enough.', + 'I hugged the unknown. It hugged me back.', + ]; +} diff --git a/lib/widgets/action_button.dart b/lib/widgets/action_button.dart index 52e9d26..eefb8ed 100644 --- a/lib/widgets/action_button.dart +++ b/lib/widgets/action_button.dart @@ -43,16 +43,13 @@ class _ActionButtonState extends State { Widget build(BuildContext context) { return Opacity( opacity: _buttonFadeOutAnimation.value, - child: SquareButton( + child: SquareButton.big( backgroundColor: widget.themeAssets.primaryColor, onTap: widget.recordingFinishingBool ? null : widget.recordingInProgressBool ? widget.onStopRecording : widget.onStartRecording, - sideLength: 110, - borderWidth: 2.5, - borderRadius: 20, child: Icon( widget.recordingFinishingBool ? Icons.stop_rounded diff --git a/lib/widgets/buttons_panel.dart b/lib/widgets/buttons_panel.dart new file mode 100644 index 0000000..8085706 --- /dev/null +++ b/lib/widgets/buttons_panel.dart @@ -0,0 +1,79 @@ +import 'package:flutter/material.dart'; +import 'package:whisp/cubit/theme_cubit/theme_assets.dart'; +import 'package:whisp/widgets/square_button.dart'; + +class ButtonsPanel extends StatelessWidget { + final bool emissionInProgressBool; + final bool msgEmptyBool; + final ThemeAssets themeAssets; + final VoidCallback onSaveButtonPressed; + final VoidCallback onPlayButtonPressed; + final VoidCallback onStopButtonPressed; + final VoidCallback onShareButtonPressed; + + const ButtonsPanel({ + required this.emissionInProgressBool, + required this.msgEmptyBool, + required this.themeAssets, + required this.onSaveButtonPressed, + required this.onPlayButtonPressed, + required this.onStopButtonPressed, + required this.onShareButtonPressed, + super.key, + }); + + @override + Widget build(BuildContext context) { + bool buttonsDisabledBool = emissionInProgressBool || msgEmptyBool; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Spacer(flex: 2), + Expanded( + flex: 8, + child: SquareButton.medium( + backgroundColor: themeAssets.primaryColor, + onTap: buttonsDisabledBool ? null : onSaveButtonPressed, + child: const Icon( + Icons.save_alt, + size: 40, + ), + ), + ), + const Spacer(flex: 2), + Expanded( + flex: 10, + child: SquareButton.big( + backgroundColor: themeAssets.primaryColor, + onTap: msgEmptyBool + ? null + : emissionInProgressBool + ? onStopButtonPressed + : onPlayButtonPressed, + child: Icon( + emissionInProgressBool ? Icons.stop_rounded : Icons.play_arrow_rounded, + size: 80, + color: emissionInProgressBool ? const Color(0xff244064) : const Color(0xff396521), + ), + ), + ), + const Spacer(flex: 2), + Expanded( + flex: 8, + child: SquareButton.medium( + backgroundColor: themeAssets.primaryColor, + onTap: buttonsDisabledBool ? null : onShareButtonPressed, + child: const Icon( + Icons.share, + size: 40, + ), + ), + ), + const Spacer(flex: 2), + ], + ), + ); + } +} diff --git a/lib/widgets/custom_app_bar.dart b/lib/widgets/custom_app_bar.dart new file mode 100644 index 0000000..fa20c27 --- /dev/null +++ b/lib/widgets/custom_app_bar.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; +import 'package:whisp/widgets/icons_alignment.dart'; + +class CustomAppBar extends StatelessWidget { + final double iconsOpacity; + final IconsAlignment iconsAlignment; + final List iconButtons; + final int totalSlots; + + const CustomAppBar({ + this.iconsOpacity = 1, + this.iconsAlignment = IconsAlignment.start, + this.iconButtons = const [], + this.totalSlots = 7, + Key? key, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + List fittedWidgets = iconButtons + .take(totalSlots) + .map((Widget iconButton) => Expanded( + child: FittedBox( + fit: BoxFit.scaleDown, + child: iconButton, + ))) + .toList(); + + int spacerCount = totalSlots - fittedWidgets.length; + List spacers = List.generate( + spacerCount.clamp(0, totalSlots), + (_) => Expanded( + child: SizedBox( + height: MediaQuery.of(context).size.width * 0.18, + ), + ), + ); + + List finalChildren = iconsAlignment == IconsAlignment.start ? [...fittedWidgets, ...spacers] : [...spacers, ...fittedWidgets]; + + return Opacity( + opacity: iconsOpacity, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10), + child: Row(children: finalChildren), + ), + ); + } +} diff --git a/lib/widgets/decoded_msg/decoded_msg_section.dart b/lib/widgets/decoded_msg/decoded_msg_section.dart index 9bed536..c0c3429 100644 --- a/lib/widgets/decoded_msg/decoded_msg_section.dart +++ b/lib/widgets/decoded_msg/decoded_msg_section.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:whisp/cubit/theme_cubit/theme_assets.dart'; +import 'package:whisp/widgets/custom_app_bar.dart'; import 'package:whisp/widgets/custom_back_button.dart'; import 'package:whisp/widgets/decoded_msg/decoded_msg.dart'; import 'package:whisp/widgets/decoded_msg/decoded_msg_background.dart'; @@ -37,23 +38,13 @@ class DecodedMsgSection extends StatelessWidget { SafeArea( child: Column( children: [ - Row( - children: [ - Expanded( - flex: 1, - child: FittedBox( - fit: BoxFit.scaleDown, - child: Padding( - padding: const EdgeInsets.only(left: 10, top: 12, bottom: 12), - child: CustomBackButton( - fadeInAnimationController: backButtonFadeInController, - color: themeAssets.primaryColor, - onPopInvoked: onBackButtonPressed, - ), - ), - ), + CustomAppBar( + iconButtons: [ + CustomBackButton( + fadeInAnimationController: backButtonFadeInController, + color: themeAssets.primaryColor, + onPopInvoked: onBackButtonPressed, ), - const Spacer(flex: 5), ], ), Expanded( diff --git a/lib/widgets/dice_button.dart b/lib/widgets/dice_button.dart new file mode 100644 index 0000000..02a9173 --- /dev/null +++ b/lib/widgets/dice_button.dart @@ -0,0 +1,57 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:whisp/widgets/square_button.dart'; + +class DiceButton extends StatefulWidget { + final bool disabledBool; + final int maxRollNumber; + final Color color; + final ValueChanged onRoll; + + const DiceButton({ + required this.disabledBool, + required this.maxRollNumber, + required this.color, + required this.onRoll, + super.key, + }); + + @override + State createState() => _DiceButtonState(); +} + +class _DiceButtonState extends State { + final List _diceIconsPaths = [ + 'assets/dice-1.svg', + 'assets/dice-2.svg', + 'assets/dice-3.svg', + 'assets/dice-4.svg', + 'assets/dice-5.svg', + 'assets/dice-6.svg', + ]; + int _diceIndex = Random().nextInt(6); + + @override + Widget build(BuildContext context) { + return SquareButton.small( + backgroundColor: widget.color, + onTap: widget.disabledBool ? null : _rollDice, + child: Padding( + padding: const EdgeInsets.all(4.0), + child: SvgPicture.asset( + _diceIconsPaths[_diceIndex], + ), + ), + ); + } + + void _rollDice() { + setState(() { + int randomIndex = Random().nextInt(widget.maxRollNumber - 1); + widget.onRoll(randomIndex); + _diceIndex = Random().nextInt(6); + }); + } +} diff --git a/lib/widgets/icons_alignment.dart b/lib/widgets/icons_alignment.dart new file mode 100644 index 0000000..ecd3be7 --- /dev/null +++ b/lib/widgets/icons_alignment.dart @@ -0,0 +1,4 @@ +enum IconsAlignment { + start, + end, +} diff --git a/lib/widgets/message_form/message_form.dart b/lib/widgets/message_form/message_form.dart new file mode 100644 index 0000000..2d1ccd7 --- /dev/null +++ b/lib/widgets/message_form/message_form.dart @@ -0,0 +1,156 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:particles_flutter/component/particle/particle.dart'; +import 'package:particles_flutter/particles_engine.dart'; +import 'package:whisp/cubit/theme_cubit/theme_assets.dart'; +import 'package:whisp/shared/utils/predefined_messages.dart'; +import 'package:whisp/widgets/dice_button.dart'; +import 'package:whisp/widgets/message_form/message_form_text_field.dart'; +import 'package:whisp/widgets/square_button.dart'; + +class MessageForm extends StatelessWidget { + final bool clearButtonDisabledBool; + final bool emissionInProgressBool; + final bool showPlaceholderBool; + final FocusNode focusNode; + final ThemeAssets themeAssets; + final TextEditingController messageTextController; + final VoidCallback onClearButtonPressed; + + const MessageForm({ + required this.clearButtonDisabledBool, + required this.emissionInProgressBool, + required this.showPlaceholderBool, + required this.focusNode, + required this.themeAssets, + required this.messageTextController, + required this.onClearButtonPressed, + super.key, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + flex: 4, + child: LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + return Stack( + children: [ + MessageFormTextField( + emissionInProgressBool: emissionInProgressBool, + fillColor: themeAssets.primaryColor, + focusNode: focusNode, + messageTextController: messageTextController, + outlineInputBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Colors.black, width: 2), + ), + ), + Positioned( + child: IgnorePointer( + child: ClipRRect( + borderRadius: BorderRadius.circular(20), + child: Particles( + awayRadius: 150, + particles: _createParticles(), + height: constraints.maxHeight - 10, + width: constraints.maxWidth, + onTapAnimation: true, + awayAnimationDuration: const Duration(milliseconds: 100), + awayAnimationCurve: Curves.linear, + enableHover: true, + hoverRadius: 90, + connectDots: false, + ), + ), + ), + ), + if (showPlaceholderBool) + Positioned( + left: 14, + top: 8, + child: Text( + 'Type your message', + style: TextStyle(color: Colors.black38, fontSize: MediaQuery.of(context).size.width * 0.06, fontFamily: 'Kalam'), + ), + ), + Positioned( + right: 8, + bottom: 11, + child: ValueListenableBuilder( + valueListenable: messageTextController, + builder: (BuildContext context, TextEditingValue textEditingValue, _) { + return Text( + '${textEditingValue.text.length}/300', + style: TextStyle( + fontFamily: 'Pacifico', + fontWeight: FontWeight.bold, + letterSpacing: 2, + fontSize: MediaQuery.of(context).size.width * 0.04, + color: Colors.black54, + ), + ); + }, + ), + ), + ], + ); + }, + ), + ), + Expanded( + flex: 1, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DiceButton( + disabledBool: emissionInProgressBool, + maxRollNumber: PredefinedMessages.exampleMessages.length, + color: themeAssets.primaryColor, + onRoll: _onRoll, + ), + SquareButton.small( + backgroundColor: themeAssets.primaryColor, + onTap: clearButtonDisabledBool ? null : onClearButtonPressed, + child: const Icon( + Icons.close, + size: 36, + ), + ) + ], + ), + ), + ], + ), + ); + } + + List _createParticles() { + Random rng = Random(); + List particles = []; + for (int i = 0; i < 10; i++) { + particles.add(Particle( + color: themeAssets.particlesColor, + size: rng.nextDouble() * 10, + velocity: Offset(rng.nextDouble() * 50 * _randomSign(), rng.nextDouble() * 50 * _randomSign()), + )); + } + return particles; + } + + double _randomSign() { + Random rng = Random(); + return rng.nextBool() ? 1 : -1; + } + + void _onRoll(int result) { + messageTextController.text = PredefinedMessages.exampleMessages[result]; + } +} diff --git a/lib/widgets/message_form/message_form_text_field.dart b/lib/widgets/message_form/message_form_text_field.dart new file mode 100644 index 0000000..86c407e --- /dev/null +++ b/lib/widgets/message_form/message_form_text_field.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +class MessageFormTextField extends StatelessWidget { + final bool emissionInProgressBool; + final Color fillColor; + final FocusNode focusNode; + final InputBorder outlineInputBorder; + final TextEditingController messageTextController; + + const MessageFormTextField({ + required this.emissionInProgressBool, + required this.fillColor, + required this.focusNode, + required this.outlineInputBorder, + required this.messageTextController, + super.key, + }); + + @override + Widget build(BuildContext context) { + return TextFormField( + maxLength: 300, + maxLengthEnforcement: MaxLengthEnforcement.enforced, + focusNode: focusNode, + controller: messageTextController, + enabled: emissionInProgressBool == false, + maxLines: null, + expands: true, + cursorColor: Colors.black, + cursorHeight: 22, + style: TextStyle(color: Colors.black, fontSize: MediaQuery.of(context).size.width * 0.06, fontFamily: 'Kalam'), + textAlignVertical: TextAlignVertical.top, + decoration: InputDecoration( + filled: true, + fillColor: fillColor, + counter: const SizedBox.shrink(), + enabledBorder: outlineInputBorder, + focusedBorder: outlineInputBorder, + disabledBorder: outlineInputBorder, + floatingLabelBehavior: FloatingLabelBehavior.never, + contentPadding: const EdgeInsets.fromLTRB(12, 12, 4, 30), + ), + ); + } +} diff --git a/lib/widgets/settings_button.dart b/lib/widgets/settings_button.dart index 8e7925d..a7cbdc3 100644 --- a/lib/widgets/settings_button.dart +++ b/lib/widgets/settings_button.dart @@ -36,18 +36,15 @@ class _SettingsButtonState extends State { Widget build(BuildContext context) { return Opacity( opacity: _buttonFadeOutAnimation.value, - child: Padding( - padding: const EdgeInsets.only(left: 10, top: 5), - child: IconButton( - icon: OutlinedIcon( - icon: Icons.settings, - outlineColor: Colors.black, - fillColor: widget.color, - outlineWidth: 4, - size: 40, - ), - onPressed: widget.onPressed, + child: IconButton( + icon: OutlinedIcon( + icon: Icons.settings, + outlineColor: Colors.black, + fillColor: widget.color, + outlineWidth: 4, + size: 40, ), + onPressed: widget.onPressed, ), ); } diff --git a/lib/widgets/square_button.dart b/lib/widgets/square_button.dart index 3835112..eb2ff08 100644 --- a/lib/widgets/square_button.dart +++ b/lib/widgets/square_button.dart @@ -8,15 +8,32 @@ class SquareButton extends StatelessWidget { final double borderWidth; final double borderRadius; - const SquareButton({ + const SquareButton.small({ required this.backgroundColor, required this.child, required this.onTap, - this.sideLength = 50, - this.borderWidth = 2, - this.borderRadius = 10, super.key, - }); + }) : sideLength = 60, + borderWidth = 2, + borderRadius = 10; + + const SquareButton.medium({ + required this.backgroundColor, + required this.child, + required this.onTap, + super.key, + }) : sideLength = 80, + borderWidth = 2.5, + borderRadius = 16; + + const SquareButton.big({ + required this.backgroundColor, + required this.child, + required this.onTap, + super.key, + }) : sideLength = 110, + borderWidth = 2.5, + borderRadius = 20; @override Widget build(BuildContext context) { diff --git a/lib/widgets/tab_layout.dart b/lib/widgets/tab_layout.dart new file mode 100644 index 0000000..f3231f3 --- /dev/null +++ b/lib/widgets/tab_layout.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; +import 'package:whisp/widgets/custom_app_bar.dart'; + +class TabLayout extends StatelessWidget { + final CustomAppBar customAppBar; + final Widget? topWidget; + final Widget? bottomWidget; + final Widget? middleSpacerArea; + final Widget? bottomSpacerArea; + + const TabLayout({ + required this.customAppBar, + this.topWidget, + this.bottomWidget, + this.middleSpacerArea, + this.bottomSpacerArea, + super.key, + }); + + @override + Widget build(BuildContext context) { + return SafeArea( + child: Column( + children: [ + customAppBar, + if (topWidget != null) Expanded(flex: 7, child: topWidget!) else const Spacer(flex: 7), + if (middleSpacerArea != null) Expanded(child: middleSpacerArea!) else const Spacer(), + if (bottomWidget != null) Expanded(flex: 4, child: bottomWidget!) else const Spacer(flex: 4), + if (bottomSpacerArea != null) Expanded(child: bottomSpacerArea!) else const Spacer(), + ], + ), + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 39ca3cd..ef9e263 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: whisp description: "Demo application for mrumru." publish_to: 'none' -version: 0.0.2 +version: 0.0.3 environment: sdk: "3.2.6" @@ -17,6 +17,14 @@ dependencies: url: git@github.com:snggle/mrumru.git ref: dev + # A package that allows you to use the native file explorer to pick single or multiple files, with extensions filtering support. + # https://pub.dev/packages/file_picker + file_picker: 8.0.0 + + # Draw SVG files using Flutter. + # https://pub.dev/packages/flutter_svg + flutter_svg: 2.0.10+1 + # A Dart package that helps to implement value based equality without needing to explicitly override == and hashCode. # https://pub.dev/packages/equatable equatable: 2.0.5 @@ -33,6 +41,10 @@ dependencies: # https://pub.dev/packages/particles_flutter particles_flutter: 1.0.1 + # A comprehensive, cross-platform path manipulation library for Dart. + # https://pub.dev/packages/path + path: 1.8.3 + # A Flutter plugin for finding commonly used locations on the filesystem. # https://pub.dev/packages/path_provider path_provider: 2.1.4 @@ -49,6 +61,10 @@ dependencies: # https://pub.dev/packages/window_manager window_manager: 0.4.3 + # A Flutter plugin to share content from your Flutter app via the platform's share dialog. + # https://pub.dev/packages/share_plus + share_plus: 7.2.2 + dev_dependencies: flutter_test: sdk: flutter @@ -61,6 +77,12 @@ flutter: - assets/cream_cloud_anim.webp - assets/cream_cloud.png - assets/snggle_face.gif + - assets/dice-1.svg + - assets/dice-2.svg + - assets/dice-3.svg + - assets/dice-4.svg + - assets/dice-5.svg + - assets/dice-6.svg fonts: - family: Kalam fonts: diff --git a/test/unit/placeholder_test.dart b/test/unit/placeholder_test.dart deleted file mode 100644 index 66b485b..0000000 --- a/test/unit/placeholder_test.dart +++ /dev/null @@ -1,8 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; - -void main() { - // This test was added to support GitHub workflows with private repo access config, in case tests are added in the future (placeholder can be replaced with real tests) - test('Placeholder test', () { - expect(1, 1); - }); -} \ No newline at end of file diff --git a/test/unit/shared/utils/file_utils_test.dart b/test/unit/shared/utils/file_utils_test.dart new file mode 100644 index 0000000..f029fd0 --- /dev/null +++ b/test/unit/shared/utils/file_utils_test.dart @@ -0,0 +1,60 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:whisp/shared/utils/file_utils.dart'; + +void main() { + group('Tests of FileUtils.fixFileNameCounter()', () { + test('Should [move the number] before the extension', () async { + // Act + String actualFixedPath = await FileUtils.fixFileNameCounter('test/generated-sound.wav (1)', []); + + // Assert + String expectedFixedPath = 'test/generated-sound (1).wav'; + + expect(actualFixedPath, expectedFixedPath); + }); + + test('Should [move the number] before the extension and [increment it], if the [name already exists]', () async { + // Act + String actualFixedPath = await FileUtils.fixFileNameCounter('test/generated-sound.wav (1)', [ + 'generated-sound.wav', + 'generated-sound (1).wav', + 'generated-sound (2).wav', + ]); + + // Assert + String expectedFixedPath = 'test/generated-sound (3).wav'; + + expect(actualFixedPath, expectedFixedPath); + }); + + test('Should [move the suffix] before the extension', () async { + // Act + String actualFixedPath = await FileUtils.fixFileNameCounter('test/generated-sound.wavaaa', []); + + // Assert + String expectedFixedPath = 'test/generated-soundaaa.wav'; + + expect(actualFixedPath, expectedFixedPath); + }); + + test('Should [add the missing .wav] extension', () async { + // Act + String actualFixedPath = await FileUtils.fixFileNameCounter('test/generated-sound', []); + + // Assert + String expectedFixedPath = 'test/generated-sound.wav'; + + expect(actualFixedPath, expectedFixedPath); + }); + + test('Should [do nothing] if [name correct]', () async { + // Act + String actualFixedPath = await FileUtils.fixFileNameCounter('test/generated-sound.wav', []); + + // Assert + String expectedFixedPath = 'test/generated-sound.wav'; + + expect(actualFixedPath, expectedFixedPath); + }); + }); +} diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 67a278c..33d9e99 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -9,6 +9,8 @@ #include #include #include +#include +#include #include #include @@ -19,6 +21,10 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("RecordWindowsPluginCApi")); ScreenRetrieverWindowsPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("ScreenRetrieverWindowsPluginCApi")); + SharePlusWindowsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi")); + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); Win32audioPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("Win32audioPluginCApi")); WindowManagerPluginRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 9a1d091..0093339 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -6,6 +6,8 @@ list(APPEND FLUTTER_PLUGIN_LIST permission_handler_windows record_windows screen_retriever_windows + share_plus + url_launcher_windows win32audio window_manager )