diff --git a/.github/workflows/version_and_tests.yaml b/.github/workflows/version_and_tests.yaml index 762918b..e953f3b 100644 --- a/.github/workflows/version_and_tests.yaml +++ b/.github/workflows/version_and_tests.yaml @@ -56,6 +56,10 @@ jobs: steps: # https://github.com/marketplace/actions/checkout - uses: actions/checkout@main + # https://github.com/webfactory/ssh-agent + - uses: webfactory/ssh-agent@v0.9.0 + with: + ssh-private-key: ${{ secrets.WHISP_SSH_PRIVATE_KEY }} # https://github.com/marketplace/actions/flutter-action - name: Extract flutter SDK version from FVM run: echo "FLUTTER_SDK_VERSION=$(jq -r '.flutterSdkVersion' .fvm/fvm_config.json)" >> $GITHUB_ENV diff --git a/.gitignore b/.gitignore index b85aea4..749f43a 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,7 @@ # Miscellaneous *.class -#*.lock +*.lock *.log *.pyc *.swp diff --git a/README.md b/README.md index e072fbd..b5ccf0e 100644 --- a/README.md +++ b/README.md @@ -43,19 +43,4 @@ fvm flutter pub run build_runner watch --delete-conflicting-outputs ## 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. -## [Licence](./LICENSE.md) - -A new Flutter project. - -## Getting Started - -This project is a starting point for a Flutter application. - -A few resources to get you started if this is your first Flutter project: - -- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) -- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook) - -For help getting started with Flutter development, view the -[online documentation](https://docs.flutter.dev/), which offers tutorials, -samples, guidance on mobile development, and a full API reference. +## [Licence](./LICENSE.md) \ No newline at end of file diff --git a/assets/blue_cloud.png b/assets/blue_cloud.png new file mode 100644 index 0000000..c4805cd Binary files /dev/null and b/assets/blue_cloud.png differ diff --git a/assets/blue_cloud_anim.webp b/assets/blue_cloud_anim.webp new file mode 100644 index 0000000..133f614 Binary files /dev/null and b/assets/blue_cloud_anim.webp differ diff --git a/assets/cream_cloud.png b/assets/cream_cloud.png new file mode 100644 index 0000000..fafa218 Binary files /dev/null and b/assets/cream_cloud.png differ diff --git a/assets/cream_cloud_anim.webp b/assets/cream_cloud_anim.webp new file mode 100644 index 0000000..7844e2e Binary files /dev/null and b/assets/cream_cloud_anim.webp differ diff --git a/assets/fonts/Kalam-Bold.ttf b/assets/fonts/Kalam-Bold.ttf new file mode 100644 index 0000000..321272d Binary files /dev/null and b/assets/fonts/Kalam-Bold.ttf differ diff --git a/assets/snggle_face.gif b/assets/snggle_face.gif new file mode 100644 index 0000000..440efcb Binary files /dev/null and b/assets/snggle_face.gif differ diff --git a/lib/cubit/receive_tab_cubit/a_receive_tab_state.dart b/lib/cubit/receive_tab_cubit/a_receive_tab_state.dart new file mode 100644 index 0000000..4565de8 --- /dev/null +++ b/lib/cubit/receive_tab_cubit/a_receive_tab_state.dart @@ -0,0 +1,5 @@ +import 'package:equatable/equatable.dart'; + +abstract class AReceiveTabState extends Equatable { + const AReceiveTabState(); +} diff --git a/lib/cubit/receive_tab_cubit/receive_tab_cubit.dart b/lib/cubit/receive_tab_cubit/receive_tab_cubit.dart new file mode 100644 index 0000000..d6bd81f --- /dev/null +++ b/lib/cubit/receive_tab_cubit/receive_tab_cubit.dart @@ -0,0 +1,78 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:mrumru/mrumru.dart'; +import 'package:whisp/cubit/receive_tab_cubit/a_receive_tab_state.dart'; +import 'package:whisp/cubit/receive_tab_cubit/states/receive_tab_empty_state.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/receive_tab_cubit/states/receive_tab_result_state.dart'; +import 'package:whisp/shared/audio_settings_mode.dart'; +import 'package:whisp/shared/utils/logger/app_logger.dart'; + +class ReceiveTabCubit extends Cubit { + AudioSettingsModel _audioSettingsModel = AudioSettingsModel( + frequencyGenerator: MusicalFrequencyGenerator( + frequencies: MusicalFrequencies.fdm9FullScaleAMaj, + ), + ); + late AudioDecoder _audioDecoder; + bool _canceledByUserBool = false; + + ReceiveTabCubit() : super(const ReceiveTabEmptyState()); + + void resetScreen() { + emit(const ReceiveTabEmptyState()); + } + + void switchAudioType(AudioSettingsMode audioSettingsMode) { + if (audioSettingsMode == AudioSettingsMode.rocket) { + _audioSettingsModel = AudioSettingsModel(frequencyGenerator: StandardFrequencyGenerator(subbandCount: 32)); + } else { + _audioSettingsModel = AudioSettingsModel( + frequencyGenerator: MusicalFrequencyGenerator( + frequencies: MusicalFrequencies.fdm9FullScaleAMaj, + ), + ); + } + } + + void startRecording() { + try { + _audioDecoder = AudioDecoder( + audioSettingsModel: _audioSettingsModel, + onMetadataFrameReceived: _handleMetadataFrameReceived, + onDecodingCompleted: _handleDecodingCompleted, + onDecodingFailed: _handleDecodingFailed, + ); + emit(const ReceiveTabRecordingState()); + _audioDecoder.startRecording(); + } catch (e) { + AppLogger().log(message: 'Cannot start recording: $e'); + emit(const ReceiveTabEmptyState()); + } + } + + void stopRecording() { + _canceledByUserBool = true; + emit(const ReceiveTabEmptyState()); + _audioDecoder.cancelRecording(); + } + + void _handleDecodingCompleted(FrameCollectionModel frameCollectionModel) { + if (_canceledByUserBool == false) { + List decodedMessagePartList = frameCollectionModel.getMessageParts(); + emit(ReceiveTabResultState( + decodedMessagePartList: decodedMessagePartList, + brokenMessageIndexList: frameCollectionModel.getBrokenDataFrameIndexes(), + )); + } + _canceledByUserBool = false; + } + + void _handleDecodingFailed() { + emit(const ReceiveTabFailedState()); + } + + void _handleMetadataFrameReceived(MetadataFrameModel metadataFrameModel) { + emit(const ReceiveTabRecordingState(decodingBool: true)); + } +} diff --git a/lib/cubit/receive_tab_cubit/states/receive_tab_empty_state.dart b/lib/cubit/receive_tab_cubit/states/receive_tab_empty_state.dart new file mode 100644 index 0000000..973b865 --- /dev/null +++ b/lib/cubit/receive_tab_cubit/states/receive_tab_empty_state.dart @@ -0,0 +1,8 @@ +import 'package:whisp/cubit/receive_tab_cubit/a_receive_tab_state.dart'; + +class ReceiveTabEmptyState extends AReceiveTabState { + const ReceiveTabEmptyState(); + + @override + List get props => []; +} diff --git a/lib/cubit/receive_tab_cubit/states/receive_tab_failed_state.dart b/lib/cubit/receive_tab_cubit/states/receive_tab_failed_state.dart new file mode 100644 index 0000000..3179148 --- /dev/null +++ b/lib/cubit/receive_tab_cubit/states/receive_tab_failed_state.dart @@ -0,0 +1,8 @@ +import 'package:whisp/cubit/receive_tab_cubit/a_receive_tab_state.dart'; + +class ReceiveTabFailedState extends AReceiveTabState { + const ReceiveTabFailedState(); + + @override + List get props => []; +} diff --git a/lib/cubit/receive_tab_cubit/states/receive_tab_recording_state.dart b/lib/cubit/receive_tab_cubit/states/receive_tab_recording_state.dart new file mode 100644 index 0000000..8985b8b --- /dev/null +++ b/lib/cubit/receive_tab_cubit/states/receive_tab_recording_state.dart @@ -0,0 +1,12 @@ +import 'package:whisp/cubit/receive_tab_cubit/a_receive_tab_state.dart'; + +class ReceiveTabRecordingState extends AReceiveTabState { + final bool decodingBool; + + const ReceiveTabRecordingState({ + this.decodingBool = false, + }); + + @override + List get props => [decodingBool]; +} diff --git a/lib/cubit/receive_tab_cubit/states/receive_tab_result_state.dart b/lib/cubit/receive_tab_cubit/states/receive_tab_result_state.dart new file mode 100644 index 0000000..9391a78 --- /dev/null +++ b/lib/cubit/receive_tab_cubit/states/receive_tab_result_state.dart @@ -0,0 +1,17 @@ +import 'package:whisp/cubit/receive_tab_cubit/a_receive_tab_state.dart'; + +class ReceiveTabResultState extends AReceiveTabState { + final List decodedMessagePartList; + final List brokenMessageIndexList; + + const ReceiveTabResultState({ + required this.decodedMessagePartList, + required this.brokenMessageIndexList, + }); + + @override + List get props => [ + decodedMessagePartList, + brokenMessageIndexList, + ]; +} diff --git a/lib/cubit/theme_cubit/a_theme_state.dart b/lib/cubit/theme_cubit/a_theme_state.dart new file mode 100644 index 0000000..082d9bf --- /dev/null +++ b/lib/cubit/theme_cubit/a_theme_state.dart @@ -0,0 +1,10 @@ +import 'package:equatable/equatable.dart'; +import 'package:whisp/cubit/theme_cubit/theme_assets.dart'; + +abstract class AThemeState extends Equatable { + final ThemeAssets themeAssets; + + const AThemeState({ + required this.themeAssets, + }); +} diff --git a/lib/cubit/theme_cubit/states/theme_dark_state.dart b/lib/cubit/theme_cubit/states/theme_dark_state.dart new file mode 100644 index 0000000..9c73419 --- /dev/null +++ b/lib/cubit/theme_cubit/states/theme_dark_state.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; +import 'package:whisp/cubit/theme_cubit/a_theme_state.dart'; +import 'package:whisp/cubit/theme_cubit/theme_assets.dart'; +import 'package:whisp/widgets/outlined_icon.dart'; + +class ThemeDarkState extends AThemeState { + ThemeDarkState() + : super( + themeAssets: ThemeAssets( + cloudStill: Image.asset('assets/blue_cloud.png', fit: BoxFit.cover), + cloudMoving: Image.asset('assets/blue_cloud_anim.webp', fit: BoxFit.cover, filterQuality: FilterQuality.none), + snggleFace: Image.asset('assets/snggle_face.gif', fit: BoxFit.contain), + backgroundColor: const Color(0xff245161), + primaryColor: const Color(0xffd6e7f2), + particlesColor: const Color(0xff5e676c).withOpacity(0.1), + textColor: Colors.white, + themeChangeIcon: const OutlinedIcon( + icon: Icons.nightlight, + outlineWidth: 4, + outlineColor: Colors.black, + fillColor: Color(0xffd6e7f2), + size: 40, + ), + ), + ); + + @override + List get props => [themeAssets]; +} diff --git a/lib/cubit/theme_cubit/states/theme_light_state.dart b/lib/cubit/theme_cubit/states/theme_light_state.dart new file mode 100644 index 0000000..63167f3 --- /dev/null +++ b/lib/cubit/theme_cubit/states/theme_light_state.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; +import 'package:whisp/cubit/theme_cubit/a_theme_state.dart'; +import 'package:whisp/cubit/theme_cubit/theme_assets.dart'; +import 'package:whisp/widgets/outlined_icon.dart'; + +class ThemeLightState extends AThemeState { + ThemeLightState() + : super( + themeAssets: ThemeAssets( + cloudStill: Image.asset('assets/cream_cloud.png', fit: BoxFit.cover), + cloudMoving: Image.asset('assets/cream_cloud_anim.webp', fit: BoxFit.cover, filterQuality: FilterQuality.none), + snggleFace: Image.asset('assets/snggle_face.gif', fit: BoxFit.contain), + backgroundColor: const Color(0xffecad9d), + primaryColor: const Color(0xffffead2), + particlesColor: const Color(0xff726a60).withOpacity(0.1), + textColor: Colors.black, + themeChangeIcon: const OutlinedIcon( + icon: Icons.sunny, + outlineWidth: 4, + outlineColor: Colors.black, + fillColor: Color(0xffffead2), + size: 40, + ), + ), + ); + + @override + List get props => [themeAssets]; +} diff --git a/lib/cubit/theme_cubit/theme_assets.dart b/lib/cubit/theme_cubit/theme_assets.dart new file mode 100644 index 0000000..e67047b --- /dev/null +++ b/lib/cubit/theme_cubit/theme_assets.dart @@ -0,0 +1,28 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter/material.dart'; +import 'package:whisp/widgets/outlined_icon.dart'; + +class ThemeAssets extends Equatable { + final Image cloudStill; + final Image cloudMoving; + final Image snggleFace; + final Color backgroundColor; + final Color primaryColor; + final Color particlesColor; + final Color textColor; + final OutlinedIcon themeChangeIcon; + + const ThemeAssets({ + required this.cloudStill, + required this.cloudMoving, + required this.snggleFace, + required this.backgroundColor, + required this.primaryColor, + required this.particlesColor, + required this.textColor, + required this.themeChangeIcon, + }); + + @override + List get props => [cloudStill, cloudMoving, snggleFace, backgroundColor, primaryColor, particlesColor, textColor, themeChangeIcon]; +} diff --git a/lib/cubit/theme_cubit/theme_cubit.dart b/lib/cubit/theme_cubit/theme_cubit.dart new file mode 100644 index 0000000..08dc8a0 --- /dev/null +++ b/lib/cubit/theme_cubit/theme_cubit.dart @@ -0,0 +1,16 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:whisp/cubit/theme_cubit/a_theme_state.dart'; +import 'package:whisp/cubit/theme_cubit/states/theme_dark_state.dart'; +import 'package:whisp/cubit/theme_cubit/states/theme_light_state.dart'; + +class ThemeCubit extends Cubit { + ThemeCubit() : super(ThemeLightState()); + + void switchTheme() { + if (state is ThemeLightState) { + emit(ThemeDarkState()); + } else { + emit(ThemeLightState()); + } + } +} diff --git a/lib/main.dart b/lib/main.dart index b830b42..82852b3 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,125 +1,38 @@ -import 'package:flutter/material.dart'; - -void main() { - runApp(const MyApp()); -} +import 'dart:io'; -class MyApp extends StatelessWidget { - const MyApp({super.key}); - - // This widget is the root of your application. - @override - Widget build(BuildContext context) { - return MaterialApp( - title: 'Flutter Demo', - theme: ThemeData( - // This is the theme of your application. - // - // TRY THIS: Try running your application with "flutter run". You'll see - // the application has a purple toolbar. Then, without quitting the app, - // try changing the seedColor in the colorScheme below to Colors.green - // and then invoke "hot reload" (save your changes or press the "hot - // reload" button in a Flutter-supported IDE, or press "r" if you used - // the command line to start the app). - // - // Notice that the counter didn't reset back to zero; the application - // state is not lost during the reload. To reset the state, use hot - // restart instead. - // - // This works for code too, not just values: Most code changes can be - // tested with just a hot reload. - colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple), - useMaterial3: true, - ), - home: const MyHomePage(title: 'Flutter Demo Home Page'), +import 'package:flutter/material.dart'; +import 'package:whisp/page/main_page.dart'; +import 'package:window_manager/window_manager.dart'; + +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + if (Platform.isWindows) { + await windowManager.ensureInitialized(); + WindowOptions windowOptions = const WindowOptions( + size: Size(350, 550), + backgroundColor: Colors.transparent, + skipTaskbar: false, + titleBarStyle: TitleBarStyle.normal, + minimumSize: Size(320, 480), + maximumSize: Size(400, 700), ); + await windowManager.waitUntilReadyToShow(windowOptions, () async { + await windowManager.show(); + await windowManager.focus(); + }); } -} -class MyHomePage extends StatefulWidget { - const MyHomePage({required this.title, super.key}); - - // This widget is the home page of your application. It is stateful, meaning - // that it has a State object (defined below) that contains fields that affect - // how it looks. - - // This class is the configuration for the state. It holds the values (in this - // case the title) provided by the parent (in this case the App widget) and - // used by the build method of the State. Fields in a Widget subclass are - // always marked "final". - - final String title; - - @override - State createState() => _MyHomePageState(); + runApp(const CoreApp()); } -class _MyHomePageState extends State { - int _counter = 0; - - void _incrementCounter() { - setState(() { - // This call to setState tells the Flutter framework that something has - // changed in this State, which causes it to rerun the build method below - // so that the display can reflect the updated values. If we changed - // _counter without calling setState(), then the build method would not be - // called again, and so nothing would appear to happen. - _counter++; - }); - } +class CoreApp extends StatelessWidget { +const CoreApp({super.key}); @override Widget build(BuildContext context) { - // This method is rerun every time setState is called, for instance as done - // by the _incrementCounter method above. - // - // The Flutter framework has been optimized to make rerunning build methods - // fast, so that you can just rebuild anything that needs updating rather - // than having to individually change instances of widgets. - return Scaffold( - appBar: AppBar( - // TRY THIS: Try changing the color here to a specific color (to - // Colors.amber, perhaps?) and trigger a hot reload to see the AppBar - // change color while the other colors stay the same. - backgroundColor: Theme.of(context).colorScheme.inversePrimary, - // Here we take the value from the MyHomePage object that was created by - // the App.build method, and use it to set our appbar title. - title: Text(widget.title), - ), - body: Center( - // Center is a layout widget. It takes a single child and positions it - // in the middle of the parent. - child: Column( - // Column is also a layout widget. It takes a list of children and - // arranges them vertically. By default, it sizes itself to fit its - // children horizontally, and tries to be as tall as its parent. - // - // Column has various properties to control how it sizes itself and - // how it positions its children. Here we use mainAxisAlignment to - // center the children vertically; the main axis here is the vertical - // axis because Columns are vertical (the cross axis would be - // horizontal). - // - // TRY THIS: Invoke "debug painting" (choose the "Toggle Debug Paint" - // action in the IDE, or press "p" in the console), to see the - // wireframe for each widget. - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Text( - 'You have pushed the button this many times:', - ), - Text( - '$_counter', - style: Theme.of(context).textTheme.headlineMedium, - ), - ], - ), - ), - floatingActionButton: FloatingActionButton( - onPressed: _incrementCounter, - tooltip: 'Increment', - child: const Icon(Icons.add), - ), // This trailing comma makes auto-formatting nicer for build methods. + return const MaterialApp( + debugShowCheckedModeBanner: false, + home: MainPage(), ); } } diff --git a/lib/page/main_page.dart b/lib/page/main_page.dart new file mode 100644 index 0000000..e94bdb3 --- /dev/null +++ b/lib/page/main_page.dart @@ -0,0 +1,125 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +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/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/shared/audio_settings_mode.dart'; +import 'package:whisp/widgets/outlined_icon.dart'; + +class MainPage extends StatefulWidget { + const MainPage({super.key}); + + @override + State createState() => _MainPageState(); +} + +class _MainPageState extends State { + final ReceiveTabCubit _receiveTabCubit = ReceiveTabCubit(); + final ThemeCubit _themeCubit = ThemeCubit(); + + bool _iconsDisabledBool = false; + AudioSettingsMode _selectedSettingsMode = AudioSettingsMode.musical; + late PageController _pageController; + late final StreamSubscription _receiveSub; + + @override + void initState() { + super.initState(); + _pageController = PageController(); + _receiveSub = _receiveTabCubit.stream.listen((AReceiveTabState state) => _toggleIcons()); + } + + @override + void dispose() { + _pageController.dispose(); + _receiveTabCubit.close(); + _receiveSub.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, + ), + ), + 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, + 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, + ), + onPressed: _iconsDisabledBool ? null : _handleSettingsSwitched, + ), + ), + ), + Expanded( + flex: 1, + child: FittedBox( + fit: BoxFit.scaleDown, + child: IconButton( + icon: state.themeAssets.themeChangeIcon, + onPressed: _iconsDisabledBool ? null : _themeCubit.switchTheme, + ), + ), + ), + ], + ), + ), + ), + ), + ], + ), + ); + }); + } + + void _handleSettingsSwitched() { + setState(() { + int nextIndex = (_selectedSettingsMode.index + 1) % AudioSettingsMode.values.length; + _selectedSettingsMode = AudioSettingsMode.values[nextIndex]; + }); + + _receiveTabCubit.switchAudioType(_selectedSettingsMode); + } + + void _toggleIcons() { + setState(() { + _iconsDisabledBool = _receiveTabCubit.state is ReceiveTabRecordingState || _receiveTabCubit.state is ReceiveTabFailedState; + }); + } +} diff --git a/lib/page/receive_tab.dart b/lib/page/receive_tab.dart new file mode 100644 index 0000000..d37dbea --- /dev/null +++ b/lib/page/receive_tab.dart @@ -0,0 +1,344 @@ +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/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_empty_state.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/receive_tab_cubit/states/receive_tab_result_state.dart'; +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/decoded_msg/decoded_msg_section.dart'; +import 'package:whisp/widgets/device_selector.dart'; +import 'package:whisp/widgets/settings_button.dart'; +import 'package:win32audio/win32audio.dart'; + +class ReceiveTab extends StatefulWidget { + final ReceiveTabCubit receiveTabCubit; + final ThemeAssets themeAssets; + + const ReceiveTab({ + required this.receiveTabCubit, + required this.themeAssets, + super.key, + }); + + @override + State createState() => _ReceiveTabState(); +} + +class _ReceiveTabState extends State with AutomaticKeepAliveClientMixin, TickerProviderStateMixin { + late AnimationController _cloudFadeInController; + late AnimationController _cloudExpansionController; + late AnimationController _cloudBackgroundFadeInController; + late AnimationController _actionButtonFadeOutController; + late AnimationController _settingsButtonFadeOutController; + late AnimationController _snggleFaceFadeInController; + late AnimationController _msgFadeInController; + late AnimationController _backButtonFadeInController; + + bool _actionButtonDisabledBool = false; + List _brokenMessageIndexList = []; + List? _decodedMessagePartList; + List audioDeviceList = []; + + @override + void initState() { + super.initState(); + _initAnimationControllers(); + _requestMicPermission(); + _cloudFadeInController.forward(); + } + + @override + void dispose() { + _cloudFadeInController.dispose(); + _cloudExpansionController.dispose(); + _cloudBackgroundFadeInController.dispose(); + _actionButtonFadeOutController.dispose(); + _settingsButtonFadeOutController.dispose(); + _snggleFaceFadeInController.dispose(); + _msgFadeInController.dispose(); + _backButtonFadeInController.dispose(); + super.dispose(); + } + + @override + bool get wantKeepAlive => true; + + @override + Widget build(BuildContext context) { + super.build(context); + return BlocConsumer( + bloc: widget.receiveTabCubit, + listener: _animate, + builder: (BuildContext context, AReceiveTabState state) { + bool recordingInProgressBool = state is ReceiveTabRecordingState; + bool initialStateBool = state is ReceiveTabEmptyState; + + 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), + ], + ), + ) + 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( + child: ActionButton( + recordingInProgressBool: recordingInProgressBool, + recordingFinishingBool: _actionButtonDisabledBool, + fadeOutAnimationController: _actionButtonFadeOutController, + themeAssets: widget.themeAssets, + onStartRecording: _startRecording, + onStopRecording: _stopRecording, + ), + ), + ), + const Spacer(flex: 3), + ], + ), + ), + IgnorePointer( + ignoring: state is ReceiveTabResultState == false, + child: DecodedMsgSection( + backButtonFadeInController: _backButtonFadeInController, + cloudBackgroundFadeInController: _cloudBackgroundFadeInController, + msgFadeInController: _msgFadeInController, + brokenMessageIndexList: _brokenMessageIndexList, + decodedMessagePartList: _decodedMessagePartList, + onBackButtonPressed: widget.receiveTabCubit.resetScreen, + themeAssets: widget.themeAssets, + ), + ), + ], + ); + }, + ); + } + + Future _requestMicPermission() async { + Permission micPermission = Permission.microphone; + if (await micPermission.isGranted == false) { + PermissionStatus permissionStatus = await micPermission.request(); + return permissionStatus.isGranted; + } + return true; + } + + Future _showDeviceSelectionDialog(BuildContext context) async { + await _fetchAudioDevices(); + await showDialog( + context: context, + barrierDismissible: false, + builder: (BuildContext context) => AlertDialog( + title: Text( + audioDeviceList.isEmpty ? 'No microphone detected - plug audio input device' : 'Select input device', + style: const TextStyle(fontSize: 16), + ), + content: const DeviceSelector(), + actions: [ + TextButton( + onPressed: () async { + Navigator.of(context).pop(); + }, + child: const Text('OK'), + ), + ], + ), + ); + } + + void _showDeviceInfoDialog(BuildContext context) { + showDialog( + context: context, + barrierDismissible: false, + builder: (BuildContext context) => AlertDialog( + title: const Text('No microphone detected'), + content: const Text( + 'In order to use this feature, you need plug audio input device', + ), + actions: [ + TextButton( + onPressed: () async { + Navigator.of(context).pop(); + }, + child: const Text('OK'), + ), + ], + ), + ); + } + + void _showMicPermissionDialog(BuildContext context) { + showDialog( + context: context, + barrierDismissible: false, + builder: (BuildContext context) => AlertDialog( + title: const Text('No access to microphone'), + 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 { + await openAppSettings(); + Navigator.of(context).pop(); + }, + child: const Text('Go to settings'), + ), + ], + ), + ); + } + + Future _startRecording() async { + if (Platform.isWindows) { + WidgetsBinding.instance.addPostFrameCallback((_) async { + await _fetchAudioDevices(); + if (audioDeviceList.isEmpty) { + _showDeviceInfoDialog(context); + } else { + widget.receiveTabCubit.startRecording(); + } + }); + } else { + bool micPermissionGrantedBool = await _requestMicPermission(); + if (micPermissionGrantedBool) { + widget.receiveTabCubit.startRecording(); + } else { + _showMicPermissionDialog(context); + } + } + } + + Future _fetchAudioDevices() async { + if (mounted == false) { + return; + } + audioDeviceList = await Audio.enumDevices(AudioDeviceType.input) ?? []; + } + + void _stopRecording() { + setState(() { + _actionButtonDisabledBool = true; + }); + widget.receiveTabCubit.stopRecording(); + } + + void _initAnimationControllers() { + Duration animationDuration = const Duration(seconds: 1); + _cloudFadeInController = AnimationController(vsync: this, duration: animationDuration); + _cloudExpansionController = AnimationController(vsync: this, duration: animationDuration); + _cloudBackgroundFadeInController = AnimationController(vsync: this, duration: animationDuration); + _actionButtonFadeOutController = AnimationController(vsync: this, duration: animationDuration); + _settingsButtonFadeOutController = AnimationController(vsync: this, duration: animationDuration); + _snggleFaceFadeInController = AnimationController(vsync: this, duration: animationDuration); + _msgFadeInController = AnimationController(vsync: this, duration: animationDuration); + _backButtonFadeInController = AnimationController(vsync: this, duration: animationDuration); + } + + Future _animate(BuildContext context, AReceiveTabState state) async { + if (state is ReceiveTabEmptyState) { + await _snggleFaceFadeInController.reverse(); + await Future.wait([ + _backButtonFadeInController.reverse(), + _msgFadeInController.reverse(), + _cloudFadeInController.reverse(), + _cloudBackgroundFadeInController.reverse(), + ]); + setState(() { + _actionButtonDisabledBool = false; + }); + await Future.wait([ + _actionButtonFadeOutController.reverse(), + _cloudFadeInController.forward(), + _settingsButtonFadeOutController.reverse(), + ]); + } + + if (state is ReceiveTabRecordingState) { + if (state.decodingBool) { + await _snggleFaceFadeInController.forward(); + } + } + + if (state is ReceiveTabResultState) { + _decodedMessagePartList = state.decodedMessagePartList; + _brokenMessageIndexList = state.brokenMessageIndexList; + _actionButtonDisabledBool = true; + + await Future.wait([ + _settingsButtonFadeOutController.forward(), + _snggleFaceFadeInController.reverse(), + ]); + await Future.wait([ + _cloudFadeInController.reverse(), + _cloudExpansionController.forward(), + _cloudBackgroundFadeInController.forward(), + _actionButtonFadeOutController.forward(), + ]); + _actionButtonDisabledBool = false; + await Future.wait([ + _msgFadeInController.forward(), + _backButtonFadeInController.forward(), + _cloudExpansionController.reverse(), + ]); + } + + if (state is ReceiveTabFailedState) { + _decodedMessagePartList = ['too much bad energy']; + _brokenMessageIndexList = []; + _actionButtonDisabledBool = true; + await _snggleFaceFadeInController.reverse(); + await Future.wait([ + _cloudFadeInController.reverse(), + _actionButtonFadeOutController.forward(), + ]); + _actionButtonDisabledBool = false; + await Future.wait([ + _msgFadeInController.forward(), + ]); + widget.receiveTabCubit.resetScreen(); + } + } +} diff --git a/lib/shared/audio_settings_mode.dart b/lib/shared/audio_settings_mode.dart new file mode 100644 index 0000000..bf5a0ec --- /dev/null +++ b/lib/shared/audio_settings_mode.dart @@ -0,0 +1,4 @@ +enum AudioSettingsMode { + musical, + rocket, +} \ No newline at end of file diff --git a/lib/widgets/action_button.dart b/lib/widgets/action_button.dart new file mode 100644 index 0000000..52e9d26 --- /dev/null +++ b/lib/widgets/action_button.dart @@ -0,0 +1,80 @@ +import 'package:flutter/material.dart'; +import 'package:whisp/cubit/theme_cubit/theme_assets.dart'; +import 'package:whisp/widgets/square_button.dart'; + +class ActionButton extends StatefulWidget { + final bool recordingInProgressBool; + final bool recordingFinishingBool; + final AnimationController fadeOutAnimationController; + final ThemeAssets themeAssets; + final VoidCallback onStartRecording; + final VoidCallback onStopRecording; + + const ActionButton({ + required this.recordingInProgressBool, + required this.recordingFinishingBool, + required this.fadeOutAnimationController, + required this.themeAssets, + required this.onStartRecording, + required this.onStopRecording, + super.key, + }); + + @override + State createState() => _ActionButtonState(); +} + +class _ActionButtonState extends State { + late Animation _buttonFadeOutAnimation; + + @override + void initState() { + super.initState(); + _buttonFadeOutAnimation = Tween(begin: 1, end: 0).animate(widget.fadeOutAnimationController)..addListener(_rebuild); + } + + @override + void dispose() { + _buttonFadeOutAnimation.removeListener(_rebuild); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Opacity( + opacity: _buttonFadeOutAnimation.value, + child: SquareButton( + 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 + : widget.recordingInProgressBool + ? Icons.stop_rounded + : Icons.circle, + size: widget.recordingFinishingBool + ? 80 + : widget.recordingInProgressBool + ? 80 + : 50, + color: widget.recordingFinishingBool + ? const Color(0xff244064) + : widget.recordingInProgressBool + ? const Color(0xff244064) + : const Color(0xff652121), + ), + ), + ); + } + + void _rebuild() { + setState(() {}); + } +} diff --git a/lib/widgets/cartoon_cloud.dart b/lib/widgets/cartoon_cloud.dart new file mode 100644 index 0000000..e1a7fd4 --- /dev/null +++ b/lib/widgets/cartoon_cloud.dart @@ -0,0 +1,86 @@ +import 'package:flutter/material.dart'; +import 'package:whisp/cubit/theme_cubit/theme_assets.dart'; + +class CartoonCloud extends StatefulWidget { + final bool cloudMovingBool; + final bool recordingFinishingBool; + final AnimationController fadeInAnimationController; + final AnimationController expansionAnimationController; + final AnimationController snggleFaceFadeInController; + final ThemeAssets themeAssets; + + const CartoonCloud({ + required this.cloudMovingBool, + required this.recordingFinishingBool, + required this.fadeInAnimationController, + required this.expansionAnimationController, + required this.snggleFaceFadeInController, + required this.themeAssets, + super.key, + }); + + @override + State createState() => _CartoonCloudState(); +} + +class _CartoonCloudState extends State { + late Animation _fadeInAnimation; + late Animation _enlargeAnimation; + late Animation _snggleFaceFadeInAnimation; + + @override + void initState() { + super.initState(); + _fadeInAnimation = Tween(begin: 0, end: 1).animate(widget.fadeInAnimationController)..addListener(_rebuild); + _enlargeAnimation = Tween(begin: 1, end: 10).animate(widget.expansionAnimationController)..addListener(_rebuild); + _snggleFaceFadeInAnimation = Tween(begin: 0, end: 1).animate(widget.snggleFaceFadeInController)..addListener(_rebuild); + } + + @override + void dispose() { + _fadeInAnimation.removeListener(_rebuild); + _enlargeAnimation.removeListener(_rebuild); + _snggleFaceFadeInAnimation.removeListener(_rebuild); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Transform.scale( + scale: _enlargeAnimation.value, + child: ClipRect( + child: Align( + heightFactor: 0.8, + widthFactor: 0.8, + alignment: Alignment.center, + child: Opacity( + opacity: _fadeInAnimation.value, + child: Stack( + alignment: Alignment.center, + children: [ + if (widget.cloudMovingBool) widget.themeAssets.cloudMoving else widget.themeAssets.cloudStill, + Positioned.fill( + bottom: 40, + child: Opacity( + opacity: _snggleFaceFadeInAnimation.value, + child: FractionallySizedBox( + widthFactor: 0.3, + child: Image.asset( + 'assets/snggle_face.gif', + fit: BoxFit.contain, + ), + ), + ), + ), + ], + ), + ), + ), + ), + ); + } + + void _rebuild() { + setState(() {}); + } +} diff --git a/lib/widgets/custom_back_button.dart b/lib/widgets/custom_back_button.dart new file mode 100644 index 0000000..961b824 --- /dev/null +++ b/lib/widgets/custom_back_button.dart @@ -0,0 +1,68 @@ +import 'package:flutter/material.dart'; + +class CustomBackButton extends StatefulWidget { + final Color color; + final AnimationController fadeInAnimationController; + final VoidCallback? onPopInvoked; + + const CustomBackButton({ + required this.color, + required this.fadeInAnimationController, + required this.onPopInvoked, + super.key, + }); + + @override + State createState() => _CustomBackButtonState(); +} + +class _CustomBackButtonState extends State { + late Animation _fadeInAnimation; + + @override + void initState() { + super.initState(); + _fadeInAnimation = Tween(begin: 0, end: 1).animate(widget.fadeInAnimationController)..addListener(_rebuild); + } + + @override + void dispose() { + _fadeInAnimation.removeListener(_rebuild); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return PopScope( + canPop: widget.onPopInvoked == null, + onPopInvoked: (bool didPop) => widget.onPopInvoked?.call(), + child: Opacity( + opacity: _fadeInAnimation.value, + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: widget.onPopInvoked, + borderRadius: BorderRadius.circular(10), + child: Container( + height: 50, + width: 50, + decoration: BoxDecoration( + color: widget.color, + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: Colors.black, + width: 2, + ), + ), + child: const Icon(Icons.arrow_back_rounded), + ), + ), + ), + ), + ); + } + + void _rebuild() { + setState(() {}); + } +} diff --git a/lib/widgets/decoded_msg/decoded_msg.dart b/lib/widgets/decoded_msg/decoded_msg.dart new file mode 100644 index 0000000..3c05564 --- /dev/null +++ b/lib/widgets/decoded_msg/decoded_msg.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; +import 'package:whisp/widgets/decoded_msg/decoded_msg_field.dart'; + +class DecodedMsg extends StatefulWidget { + final AnimationController fadeInAnimationController; + final List brokenMessageIndexList; + final List? decodedMessagePartList; + + const DecodedMsg({ + required this.fadeInAnimationController, + required this.brokenMessageIndexList, + required this.decodedMessagePartList, + super.key, + }); + + @override + State createState() => _DecodedMsgState(); +} + +class _DecodedMsgState extends State { + late Animation _msgFadeInAnimation; + + @override + void initState() { + super.initState(); + _msgFadeInAnimation = Tween(begin: 0, end: 1).animate(widget.fadeInAnimationController)..addListener(_rebuild); + } + + @override + void dispose() { + _msgFadeInAnimation.removeListener(_rebuild); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Opacity( + opacity: _msgFadeInAnimation.value, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: widget.decodedMessagePartList == null + ? const SizedBox() + : DecodedMsgField( + brokenMessageIndexList: widget.brokenMessageIndexList, + decodedMessagePartList: widget.decodedMessagePartList!, + ), + ), + ); + } + + void _rebuild() { + setState(() {}); + } +} diff --git a/lib/widgets/decoded_msg/decoded_msg_background.dart b/lib/widgets/decoded_msg/decoded_msg_background.dart new file mode 100644 index 0000000..7d774f7 --- /dev/null +++ b/lib/widgets/decoded_msg/decoded_msg_background.dart @@ -0,0 +1,82 @@ +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'; + +class DecodedMsgBackground extends StatefulWidget { + final AnimationController fadeInAnimationController; + final ThemeAssets themeAssets; + + const DecodedMsgBackground({ + required this.fadeInAnimationController, + required this.themeAssets, + super.key, + }); + + @override + State createState() => _DecodedMsgBackgroundState(); +} + +class _DecodedMsgBackgroundState extends State { + late Animation _fadeInAnimation; + + @override + void initState() { + super.initState(); + _fadeInAnimation = Tween(begin: 0, end: 1).animate(widget.fadeInAnimationController)..addListener(_rebuild); + } + + @override + void dispose() { + _fadeInAnimation.removeListener(_rebuild); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Opacity( + opacity: _fadeInAnimation.value, + child: Container( + color: widget.themeAssets.primaryColor, + child: Particles( + awayRadius: 150, + particles: _createParticles(), + height: MediaQuery.of(context).size.height, + width: MediaQuery.of(context).size.width, + onTapAnimation: true, + awayAnimationDuration: const Duration(milliseconds: 100), + awayAnimationCurve: Curves.linear, + enableHover: true, + hoverRadius: 90, + connectDots: false, + ), + ), + ); + } + + List _createParticles() { + Random rng = Random(); + List particles = []; + for (int i = 0; i < 70; i++) { + particles.add( + Particle( + color: widget.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 _rebuild() { + setState(() {}); + } +} diff --git a/lib/widgets/decoded_msg/decoded_msg_field.dart b/lib/widgets/decoded_msg/decoded_msg_field.dart new file mode 100644 index 0000000..d808e9c --- /dev/null +++ b/lib/widgets/decoded_msg/decoded_msg_field.dart @@ -0,0 +1,42 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; + +class DecodedMsgField extends StatelessWidget { + final List decodedMessagePartList; + final List brokenMessageIndexList; + + const DecodedMsgField({ + required this.decodedMessagePartList, + this.brokenMessageIndexList = const [], + super.key, + }); + + @override + Widget build(BuildContext context) { + return RichText( + textAlign: TextAlign.center, + text: TextSpan( + children: List.generate( + decodedMessagePartList.length, + (int index) => TextSpan( + text: decodedMessagePartList[index], + style: TextStyle( + color: brokenMessageIndexList.contains(index) ? Colors.transparent : Colors.black87, + fontSize: MediaQuery.of(context).size.width * 0.06, + fontFamily: 'Kalam', + fontWeight: FontWeight.bold, + shadows: [ + Shadow( + blurRadius: Platform.isWindows ? 0.8 : 3.0, + color: Colors.black.withOpacity(0.5), + offset: Offset.zero, + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/decoded_msg/decoded_msg_section.dart b/lib/widgets/decoded_msg/decoded_msg_section.dart new file mode 100644 index 0000000..9bed536 --- /dev/null +++ b/lib/widgets/decoded_msg/decoded_msg_section.dart @@ -0,0 +1,78 @@ +import 'package:flutter/material.dart'; +import 'package:whisp/cubit/theme_cubit/theme_assets.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'; + +class DecodedMsgSection extends StatelessWidget { + final AnimationController cloudBackgroundFadeInController; + final AnimationController msgFadeInController; + final AnimationController backButtonFadeInController; + final ThemeAssets themeAssets; + final VoidCallback onBackButtonPressed; + final List brokenMessageIndexList; + final List? decodedMessagePartList; + + const DecodedMsgSection({ + required this.cloudBackgroundFadeInController, + required this.msgFadeInController, + required this.backButtonFadeInController, + required this.themeAssets, + required this.onBackButtonPressed, + required this.brokenMessageIndexList, + required this.decodedMessagePartList, + super.key, + }); + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + Positioned.fill( + child: DecodedMsgBackground( + fadeInAnimationController: cloudBackgroundFadeInController, + themeAssets: themeAssets, + ), + ), + 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, + ), + ), + ), + ), + const Spacer(flex: 5), + ], + ), + Expanded( + flex: 8, + child: Align( + alignment: Alignment.topCenter, + child: SingleChildScrollView( + child: DecodedMsg( + fadeInAnimationController: msgFadeInController, + decodedMessagePartList: decodedMessagePartList, + brokenMessageIndexList: brokenMessageIndexList, + ), + ), + ), + ), + ], + ), + ), + ], + ); + } +} diff --git a/lib/widgets/device_selector.dart b/lib/widgets/device_selector.dart new file mode 100644 index 0000000..a90a8a8 --- /dev/null +++ b/lib/widgets/device_selector.dart @@ -0,0 +1,117 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:win32audio/win32audio.dart'; + +class DeviceSelector extends StatefulWidget { + const DeviceSelector({super.key}); + + @override + State createState() => _DeviceSelectorState(); +} + +class _DeviceSelectorState extends State { + List _audioDeviceList = []; + Map _audioIdIconMap = {}; + + double _volume = 0.0; + + @override + void initState() { + super.initState(); + _reloadAudioDevices(); + } + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 500, + width: 500, + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + SizedBox( + height: 70, + child: Column( + children: [ + if (_audioDeviceList.isNotEmpty) ...[ + Center( + child: Text( + 'Volume: ${(_volume * 100).toStringAsFixed(0)}%', + style: const TextStyle(fontSize: 14), + ), + ), + Slider( + value: _volume, + min: 0, + max: 1, + divisions: 25, + onChanged: (double e) async { + await Audio.setVolume(e.toDouble(), AudioDeviceType.input); + _volume = e; + setState(() {}); + }, + ), + ], + ], + ), + ), + Expanded( + child: ListView.builder( + itemCount: _audioDeviceList.length, + itemBuilder: (BuildContext context, int index) { + return ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 0, vertical: 4), + leading: (_audioIdIconMap.containsKey(_audioDeviceList[index].id)) + ? Image.memory( + _audioIdIconMap[_audioDeviceList[index].id] ?? Uint8List(0), + width: 28, + height: 28, + gaplessPlayback: true, + ) + : const Icon(Icons.spoke_outlined), + title: Text( + _audioDeviceList[index].name, + style: const TextStyle(fontSize: 12), + ), + trailing: IconButton( + icon: Icon(_audioDeviceList[index].isActive == true ? Icons.radio_button_checked : Icons.radio_button_off), + iconSize: 16, + onPressed: () async { + await Audio.setDefaultDevice(_audioDeviceList[index].id); + await _reloadAudioDevices(); + setState(() {}); + }, + ), + ); + }, + ), + ), + const Divider( + thickness: 5, + height: 10, + color: Color.fromARGB(12, 0, 0, 0), + ), + ], + ), + ); + } + + Future _reloadAudioDevices() async { + if (mounted == false) { + return; + } + _audioDeviceList = await Audio.enumDevices(AudioDeviceType.input) ?? []; + _volume = await Audio.getVolume(AudioDeviceType.input); + + _audioIdIconMap = {}; + for (AudioDevice audioDevice in _audioDeviceList) { + if (_audioIdIconMap[audioDevice.id] == null) { + _audioIdIconMap[audioDevice.id] = await WinIcons().extractFileIcon(audioDevice.iconPath, iconID: audioDevice.iconID); + } + } + + setState(() {}); + } +} diff --git a/lib/widgets/outlined_icon.dart b/lib/widgets/outlined_icon.dart new file mode 100644 index 0000000..43993eb --- /dev/null +++ b/lib/widgets/outlined_icon.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; + +class OutlinedIcon extends StatelessWidget { + final IconData icon; + final double size; + final Color fillColor; + final Color outlineColor; + final double outlineWidth; + + const OutlinedIcon({ + required this.icon, + this.size = 32, + this.fillColor = Colors.yellow, + this.outlineColor = Colors.black, + this.outlineWidth = 3, + super.key, + }); + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + Text( + String.fromCharCode(icon.codePoint), + style: TextStyle( + fontSize: size, + fontFamily: icon.fontFamily, + package: icon.fontPackage, + foreground: Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = outlineWidth + ..color = outlineColor, + ), + ), + Text( + String.fromCharCode(icon.codePoint), + style: TextStyle( + fontSize: size, + fontFamily: icon.fontFamily, + package: icon.fontPackage, + color: fillColor, + ), + ), + ], + ); + } +} diff --git a/lib/widgets/settings_button.dart b/lib/widgets/settings_button.dart new file mode 100644 index 0000000..8e7925d --- /dev/null +++ b/lib/widgets/settings_button.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; +import 'package:whisp/widgets/outlined_icon.dart'; + +class SettingsButton extends StatefulWidget { + final AnimationController fadeOutAnimationController; + final Color color; + final VoidCallback? onPressed; + + const SettingsButton({ + required this.fadeOutAnimationController, + required this.color, + required this.onPressed, + super.key, + }); + + @override + State createState() => _SettingsButtonState(); +} + +class _SettingsButtonState extends State { + late Animation _buttonFadeOutAnimation; + + @override + void initState() { + super.initState(); + _buttonFadeOutAnimation = Tween(begin: 1, end: 0).animate(widget.fadeOutAnimationController)..addListener(_rebuild); + } + + @override + void dispose() { + _buttonFadeOutAnimation.removeListener(_rebuild); + super.dispose(); + } + + @override + 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, + ), + ), + ); + } + + void _rebuild() { + setState(() {}); + } +} diff --git a/lib/widgets/square_button.dart b/lib/widgets/square_button.dart new file mode 100644 index 0000000..3835112 --- /dev/null +++ b/lib/widgets/square_button.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; + +class SquareButton extends StatelessWidget { + final Color backgroundColor; + final Widget child; + final VoidCallback? onTap; + final double sideLength; + final double borderWidth; + final double borderRadius; + + const SquareButton({ + required this.backgroundColor, + required this.child, + required this.onTap, + this.sideLength = 50, + this.borderWidth = 2, + this.borderRadius = 10, + super.key, + }); + + @override + Widget build(BuildContext context) { + return FittedBox( + fit: BoxFit.scaleDown, + child: ClipRRect( + borderRadius: BorderRadius.circular(borderRadius), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + child: Opacity( + opacity: onTap == null ? 0.5 : 1, + child: Container( + height: sideLength, + width: sideLength, + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(borderRadius), + border: Border.all( + color: Colors.black, + width: borderWidth, + ), + ), + child: child, + ), + ), + ), + ), + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock deleted file mode 100644 index 95802b1..0000000 --- a/pubspec.lock +++ /dev/null @@ -1,385 +0,0 @@ -# Generated by pub -# See https://dart.dev/tools/pub/glossary#lockfile -packages: - async: - dependency: transitive - description: - name: async - sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" - url: "https://pub.dev" - source: hosted - version: "2.11.0" - bloc: - dependency: transitive - description: - name: bloc - sha256: "106842ad6569f0b60297619e9e0b1885c2fb9bf84812935490e6c5275777804e" - url: "https://pub.dev" - source: hosted - version: "8.1.4" - boolean_selector: - dependency: transitive - description: - name: boolean_selector - sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" - url: "https://pub.dev" - source: hosted - version: "2.1.1" - characters: - dependency: transitive - description: - name: characters - sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" - url: "https://pub.dev" - source: hosted - version: "1.3.0" - clock: - dependency: transitive - description: - name: clock - sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf - url: "https://pub.dev" - source: hosted - version: "1.1.1" - collection: - dependency: transitive - description: - name: collection - sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a - url: "https://pub.dev" - source: hosted - version: "1.18.0" - crypto: - dependency: transitive - description: - name: crypto - sha256: ff625774173754681d66daaf4a448684fb04b78f902da9cb3d308c19cc5e8bab - url: "https://pub.dev" - source: hosted - version: "3.0.3" - equatable: - dependency: "direct main" - description: - name: equatable - sha256: c2b87cb7756efdf69892005af546c56c0b5037f54d2a88269b4f347a505e3ca2 - url: "https://pub.dev" - source: hosted - version: "2.0.5" - fake_async: - dependency: transitive - description: - name: fake_async - sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" - url: "https://pub.dev" - source: hosted - version: "1.3.1" - ffi: - dependency: transitive - description: - name: ffi - sha256: "7bf0adc28a23d395f19f3f1eb21dd7cfd1dd9f8e1c50051c069122e6853bc878" - url: "https://pub.dev" - source: hosted - version: "2.1.0" - fixnum: - dependency: transitive - description: - name: fixnum - sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be - url: "https://pub.dev" - source: hosted - version: "1.1.1" - flutter: - dependency: "direct main" - description: flutter - source: sdk - version: "0.0.0" - flutter_bloc: - dependency: "direct main" - description: - name: flutter_bloc - sha256: b594505eac31a0518bdcb4b5b79573b8d9117b193cc80cc12e17d639b10aa27a - url: "https://pub.dev" - source: hosted - version: "8.1.6" - flutter_test: - dependency: "direct dev" - description: flutter - source: sdk - version: "0.0.0" - flutter_web_plugins: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" - get_it: - dependency: transitive - description: - name: get_it - sha256: e6017ce7fdeaf218dc51a100344d8cb70134b80e28b760f8bb23c242437bafd7 - url: "https://pub.dev" - source: hosted - version: "7.6.7" - js: - dependency: transitive - description: - name: js - sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 - url: "https://pub.dev" - source: hosted - version: "0.6.7" - logger: - dependency: "direct main" - description: - name: logger - sha256: "697d067c60c20999686a0add96cf6aba723b3aa1f83ecf806a8097231529ec32" - url: "https://pub.dev" - source: hosted - version: "2.4.0" - matcher: - dependency: transitive - description: - name: matcher - sha256: "1803e76e6653768d64ed8ff2e1e67bea3ad4b923eb5c56a295c3e634bad5960e" - url: "https://pub.dev" - source: hosted - version: "0.12.16" - material_color_utilities: - dependency: transitive - description: - name: material_color_utilities - sha256: "9528f2f296073ff54cb9fee677df673ace1218163c3bc7628093e7eed5203d41" - url: "https://pub.dev" - source: hosted - version: "0.5.0" - meta: - dependency: transitive - description: - name: meta - sha256: a6e590c838b18133bb482a2745ad77c5bb7715fb0451209e1a7567d416678b8e - url: "https://pub.dev" - source: hosted - version: "1.10.0" - mp_audio_stream: - dependency: transitive - description: - name: mp_audio_stream - sha256: e0f69c3ca314e3eabac91b0fc19b4d95a0c6fd84559593fc6c7e045d1c62d357 - url: "https://pub.dev" - source: hosted - version: "0.1.5" - mrumru: - dependency: "direct main" - description: - path: "." - ref: dev - resolved-ref: "9c20b7d0219dda94a9fb573db12d936a329883e1" - url: "https://github.com/snggle/mrumru.git" - source: git - version: "0.0.20" - nested: - dependency: transitive - description: - name: nested - sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" - url: "https://pub.dev" - source: hosted - version: "1.0.0" - path: - dependency: transitive - description: - name: path - sha256: "8829d8a55c13fc0e37127c29fedf290c102f4e40ae94ada574091fe0ff96c917" - url: "https://pub.dev" - source: hosted - version: "1.8.3" - plugin_platform_interface: - dependency: transitive - description: - name: plugin_platform_interface - sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" - url: "https://pub.dev" - source: hosted - version: "2.1.8" - provider: - dependency: transitive - description: - name: provider - sha256: "4abbd070a04e9ddc287673bf5a030c7ca8b685ff70218720abab8b092f53dd84" - url: "https://pub.dev" - source: hosted - version: "6.1.5" - record: - dependency: transitive - description: - path: record - ref: fork-master - resolved-ref: f5d279b5da491cb08fe823b911dea55d38a909fc - url: "https://github.com/CandyLabsIT/record.git" - source: git - version: "5.0.6" - record_android: - dependency: transitive - description: - path: record_android - ref: fork-master - resolved-ref: f5d279b5da491cb08fe823b911dea55d38a909fc - url: "https://github.com/CandyLabsIT/record.git" - source: git - version: "1.0.4" - record_darwin: - dependency: transitive - description: - path: record_darwin - ref: fork-master - resolved-ref: f5d279b5da491cb08fe823b911dea55d38a909fc - url: "https://github.com/CandyLabsIT/record.git" - source: git - version: "1.0.1" - record_linux: - dependency: transitive - description: - path: record_linux - ref: fork-master - resolved-ref: f5d279b5da491cb08fe823b911dea55d38a909fc - url: "https://github.com/CandyLabsIT/record.git" - source: git - version: "0.7.1" - record_platform_interface: - dependency: transitive - description: - name: record_platform_interface - sha256: "3a4b56e94ecd2a0b2b43eb1fa6f94c5b8484334f5d38ef43959c4bf97fb374cf" - url: "https://pub.dev" - source: hosted - version: "1.0.2" - record_web: - dependency: transitive - description: - path: record_web - ref: fork-master - resolved-ref: f5d279b5da491cb08fe823b911dea55d38a909fc - url: "https://github.com/CandyLabsIT/record.git" - source: git - version: "1.0.5" - record_windows: - dependency: transitive - description: - path: record_windows - ref: fork-master - resolved-ref: f5d279b5da491cb08fe823b911dea55d38a909fc - url: "https://github.com/CandyLabsIT/record.git" - source: git - version: "1.0.2" - sky_engine: - dependency: transitive - description: flutter - source: sdk - version: "0.0.99" - source_span: - dependency: transitive - description: - name: source_span - sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" - url: "https://pub.dev" - source: hosted - version: "1.10.0" - sprintf: - dependency: transitive - description: - name: sprintf - sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" - url: "https://pub.dev" - source: hosted - version: "7.0.0" - stack_trace: - dependency: transitive - description: - name: stack_trace - sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b" - url: "https://pub.dev" - source: hosted - version: "1.11.1" - stream_channel: - dependency: transitive - description: - name: stream_channel - sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 - url: "https://pub.dev" - source: hosted - version: "2.1.2" - stream_isolate: - dependency: transitive - description: - name: stream_isolate - sha256: "30efdab9cb66b32444378211f0ee0bd20761ee2e56af4cc0c353a3f92da1d3c3" - url: "https://pub.dev" - source: hosted - version: "0.2.0" - string_scanner: - dependency: transitive - description: - name: string_scanner - sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde" - url: "https://pub.dev" - source: hosted - version: "1.2.0" - term_glyph: - dependency: transitive - description: - name: term_glyph - sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 - url: "https://pub.dev" - source: hosted - version: "1.2.1" - test_api: - dependency: transitive - description: - name: test_api - sha256: "5c2f730018264d276c20e4f1503fd1308dfbbae39ec8ee63c5236311ac06954b" - url: "https://pub.dev" - source: hosted - version: "0.6.1" - typed_data: - dependency: transitive - description: - name: typed_data - sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c - url: "https://pub.dev" - source: hosted - version: "1.3.2" - uuid: - dependency: transitive - description: - name: uuid - sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff - url: "https://pub.dev" - source: hosted - version: "4.5.1" - vector_math: - dependency: transitive - description: - name: vector_math - sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" - url: "https://pub.dev" - source: hosted - version: "2.1.4" - wav: - dependency: transitive - description: - name: wav - sha256: ca53b6b494bbe37984dd3c7262294938b6d7a362b0f434f4c534f1ebb459f8be - url: "https://pub.dev" - source: hosted - version: "1.3.0" - web: - dependency: transitive - description: - name: web - sha256: afe077240a270dcfd2aafe77602b4113645af95d0ad31128cc02bce5ac5d5152 - url: "https://pub.dev" - source: hosted - version: "0.3.0" -sdks: - dart: "3.2.6" - flutter: ">=3.16.9" diff --git a/pubspec.yaml b/pubspec.yaml index c99c677..39ca3cd 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: whisp description: "Demo application for mrumru." publish_to: 'none' -version: 0.0.1 +version: 0.0.2 environment: sdk: "3.2.6" @@ -14,7 +14,7 @@ dependencies: # Data Over Audio mrumru: git: - url: https://github.com/snggle/mrumru.git + url: git@github.com:snggle/mrumru.git ref: dev # A Dart package that helps to implement value based equality without needing to explicitly override == and hashCode. @@ -29,9 +29,39 @@ dependencies: # https://pub.dev/packages/logger logger: 2.4.0 + # A package that provides an easy way to add particles animation in Flutter project. + # https://pub.dev/packages/particles_flutter + particles_flutter: 1.0.1 + + # A Flutter plugin for finding commonly used locations on the filesystem. + # https://pub.dev/packages/path_provider + path_provider: 2.1.4 + + # Permission plugin for Flutter. This plugin provides a cross-platform (iOS, Android) API to request and check permissions. + # https://pub.dev/packages/permission_handler + permission_handler: 11.0.1 + + # A library to get Audio Devices on Windows. + # https://pub.dev/packages/win32audio + win32audio: 1.4.0 + + # This plugin allows Flutter desktop apps to resizing and repositioning the window. + # https://pub.dev/packages/window_manager + window_manager: 0.4.3 + dev_dependencies: flutter_test: sdk: flutter flutter: uses-material-design: true + assets: + - assets/blue_cloud_anim.webp + - assets/blue_cloud.png + - assets/cream_cloud_anim.webp + - assets/cream_cloud.png + - assets/snggle_face.gif + fonts: + - family: Kalam + fonts: + - asset: assets/fonts/Kalam-Bold.ttf diff --git a/test/unit/placeholder_test.dart b/test/unit/placeholder_test.dart new file mode 100644 index 0000000..66b485b --- /dev/null +++ b/test/unit/placeholder_test.dart @@ -0,0 +1,8 @@ +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/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 458cde3..67a278c 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,9 +6,21 @@ #include "generated_plugin_registrant.h" +#include #include +#include +#include +#include void RegisterPlugins(flutter::PluginRegistry* registry) { + PermissionHandlerWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); RecordWindowsPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("RecordWindowsPluginCApi")); + ScreenRetrieverWindowsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("ScreenRetrieverWindowsPluginCApi")); + Win32audioPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("Win32audioPluginCApi")); + WindowManagerPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("WindowManagerPlugin")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index cef4df4..9a1d091 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,7 +3,11 @@ # list(APPEND FLUTTER_PLUGIN_LIST + permission_handler_windows record_windows + screen_retriever_windows + win32audio + window_manager ) list(APPEND FLUTTER_FFI_PLUGIN_LIST