diff --git a/android/app/build.gradle b/android/app/build.gradle index 57aaebe..9daee03 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -25,7 +25,7 @@ if (flutterVersionName == null) { android { namespace = "com.nankai.openpeerchat_flutter" - compileSdk = flutter.compileSdkVersion + compileSdk 34 ndkVersion = flutter.ndkVersion compileOptions { diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index eb48cb0..f447eed 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -11,8 +11,11 @@ + + + initPlayer() async { + if(!_isInitialized){ + await _player.openPlayer(); + _isInitialized=true; + } + } + + Future playAudio(String filePath) async { + await _player.startPlayer(fromURI: filePath); + } + + Future stopAudio() async { + await _player.stopPlayer(); + } + + Future dispose() async { + if(_isInitialized){ + await _player.closePlayer(); + _isInitialized=false; + } + } +} diff --git a/lib/classes/audio_recording.dart b/lib/classes/audio_recording.dart new file mode 100644 index 0000000..99d7ade --- /dev/null +++ b/lib/classes/audio_recording.dart @@ -0,0 +1,34 @@ +import 'dart:io'; +import 'package:flutter_sound/flutter_sound.dart'; +import 'package:path_provider/path_provider.dart'; + +class AudioRecorder { + final FlutterSoundRecorder _recorder = FlutterSoundRecorder(); + bool _isRecording = false; + + Future initRecorder() async { + await _recorder.openRecorder(); + } + + Future startRecording() async { + if (!_isRecording) { + Directory tempDir = await getTemporaryDirectory(); + String filePath = '${tempDir.path}/audio_${DateTime.now().millisecondsSinceEpoch}.aac'; + await _recorder.startRecorder(toFile: filePath); + _isRecording = true; + return filePath; + } + return null; + } + + Future stopRecording() async { + if (_isRecording) { + await _recorder.stopRecorder(); + _isRecording = false; + } + } + + Future dispose() async { + await _recorder.closeRecorder(); + } +} diff --git a/lib/components/message_panel.dart b/lib/components/message_panel.dart index c5db219..ffc6400 100644 --- a/lib/components/message_panel.dart +++ b/lib/components/message_panel.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:lottie/lottie.dart'; import 'package:nanoid/nanoid.dart'; import 'package:pointycastle/asymmetric/api.dart'; import 'package:provider/provider.dart'; @@ -12,10 +13,14 @@ import '../classes/payload.dart'; import '../database/database_helper.dart'; import '../encyption/rsa.dart'; import 'view_file.dart'; +import '../classes/audio_playback.dart'; +import '../classes/audio_recording.dart'; +import 'package:permission_handler/permission_handler.dart'; -/// This component is used in the ChatPage. -/// It is the message bar where the message is typed on and sent to -/// connected devices. +Future requestPermissions() async { + var micStatus = await Permission.microphone.request(); + return micStatus.isGranted; +} class MessagePanel extends StatefulWidget { const MessagePanel({Key? key, required this.converser}) : super(key: key); @@ -27,20 +32,192 @@ class MessagePanel extends StatefulWidget { class _MessagePanelState extends State { TextEditingController myController = TextEditingController(); + final AudioRecorder _audioRecorder = AudioRecorder(); + final AudioPlayer _audioPlayer = AudioPlayer(); + String? _recordingFilePath; + bool _isRecording = false; + Duration _recordingDuration = Duration.zero; File _selectedFile = File(''); + void initState() { + super.initState(); + _audioRecorder.initRecorder(); + _audioPlayer.initPlayer(); + } + + void _startRecording() async { + if (await requestPermissions()) { + String? filePath = await _audioRecorder.startRecording(); + setState(() { + _recordingFilePath = filePath; + _isRecording = true; + _recordingDuration = Duration.zero; + }); + _startRecordingTimer(); + } else { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Microphone permission denied.')), + ); + } + } + + void _stopRecording({bool cancel = false}) async { + await _audioRecorder.stopRecording(); + setState(() { + _isRecording = false; + if (cancel) { + _recordingFilePath = null; + } + }); + + if (!cancel && _recordingFilePath != null) { + _confirmAudioMessage(); + } + } + + void _startRecordingTimer() { + Future.doWhile(() async { + await Future.delayed(const Duration(seconds: 1)); + if (_isRecording) { + setState(() { + _recordingDuration += const Duration(seconds: 1); + }); + return true; + } + return false; + }); + } + + void _confirmAudioMessage() { + showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: const Text('Send Audio Message'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text('Duration: ${_recordingDuration.inSeconds} seconds'), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + IconButton( + icon: const Icon(Icons.play_arrow), + onPressed: () => _playAudio(), + ), + Lottie.asset('assets/audioAnimation.json', + height: 50, width: 50), + IconButton( + icon: const Icon(Icons.delete), + onPressed: () { + setState(() { + _recordingFilePath = null; + }); + Navigator.pop(context); + }, + ), + ], + ) + ], + ), + actions: [ + TextButton( + onPressed: () { + Navigator.pop(context); + _sendAudioMessage(); + }, + child: const Text('Send'), + ), + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + ], + ); + }, + ); + } + + void _playAudio() { + if (_recordingFilePath != null) { + _audioPlayer.playAudio(_recordingFilePath!); + } + } + + void _sendAudioMessage() async { + if (_recordingFilePath == null) return; + + // Read the audio file content + Uint8List audioBytes = await File(_recordingFilePath!).readAsBytes(); + + // Encrypt the audio file content + RSAPublicKey publicKey = Global.myPublicKey!; + Uint8List encryptedAudio = rsaEncrypt(publicKey, audioBytes); + + // Generate a unique ID for the message + var msgId = nanoid(21); + String fileName = _recordingFilePath!.split('/').last; + + // Create the encrypted message payload + String myData = jsonEncode({ + "sender": Global.myName, + "type": "audio", + "fileName": fileName, + "data": base64Encode(encryptedAudio), + }); + + String date = DateTime.now().toUtc().toString(); + + // Add the message to the local cache + Global.cache[msgId] = Payload( + msgId, + Global.myName, + widget.converser, + myData, + date, + ); + + // Save the message to the database + insertIntoMessageTable( + Payload( + msgId, + Global.myName, + widget.converser, + myData, + date, + ), + ); + + // Update the conversations in the UI + Provider.of(context, listen: false).sentToConversations( + Msg(myData, "sent", date, msgId), + widget.converser, + ); + + // Provide feedback to the user + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Audio message sent!')), + ); + + // Reset the recording state + setState(() { + _recordingFilePath = null; + _recordingDuration = Duration.zero; + }); +} + + @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.all(8.0), child: TextFormField( - //multiline text field maxLines: null, controller: myController, decoration: InputDecoration( icon: const Icon(Icons.person), hintText: 'Send Message?', - labelText: 'Send Message ', + labelText: 'Send Message', suffixIcon: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisSize: MainAxisSize.min, @@ -49,11 +226,18 @@ class _MessagePanelState extends State { onPressed: () => _navigateToFilePreviewPage(context), icon: const Icon(Icons.attach_file), ), + GestureDetector( + onLongPress: _startRecording, + onLongPressUp: () => _stopRecording(cancel: false), + onTapCancel: () => _stopRecording(cancel: true), + child: Icon( + _isRecording ? Icons.mic : Icons.mic_none, + color: _isRecording ? Colors.red : Colors.black, + ), + ), IconButton( onPressed: () => _sendMessage(context), - icon: const Icon( - Icons.send, - ), + icon: const Icon(Icons.send), ), ], ), diff --git a/lib/main.dart b/lib/main.dart index 67ce4fc..41de4be 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -8,6 +8,41 @@ import 'classes/global.dart'; import 'encyption/key_storage.dart'; import 'encyption/rsa.dart'; +final ThemeData lightTheme = ThemeData( + brightness: Brightness.light, + primaryColor: Colors.blue, + scaffoldBackgroundColor: Colors.white, + textTheme: TextTheme( + displayLarge: TextStyle( + fontSize: 24.0, + fontWeight: FontWeight.bold, + color: Colors.black, + ), + bodyLarge: TextStyle( + fontSize: 16.0, + color: Colors.black87, + ), + ), +); + +final ThemeData darkTheme = ThemeData( + brightness: Brightness.dark, + primaryColor: Colors.grey[900], + hintColor: Colors.blueAccent, + scaffoldBackgroundColor: Colors.grey[850], + textTheme: TextTheme( + displayLarge: TextStyle( + fontSize: 24.0, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + bodyLarge: TextStyle( + fontSize: 16.0, + color: Colors.white70, + ), + ), +); + void main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -48,10 +83,13 @@ class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { - return const MaterialApp( + return MaterialApp( debugShowCheckedModeBanner: false, onGenerateRoute: generateRoute, initialRoute: '/', + theme: lightTheme, + darkTheme: darkTheme, + themeMode: ThemeMode.system, ); } } diff --git a/lib/pages/chat_page.dart b/lib/pages/chat_page.dart index 33a2518..d7c2bdc 100644 --- a/lib/pages/chat_page.dart +++ b/lib/pages/chat_page.dart @@ -11,6 +11,44 @@ import 'dart:convert'; import 'package:pointycastle/asymmetric/api.dart'; import '../components/view_file.dart'; import '../encyption/rsa.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +const String themePreferenceKey = 'themePreference'; + +final ThemeData lightTheme = ThemeData( + brightness: Brightness.light, + primaryColor: Colors.blue, + scaffoldBackgroundColor: Colors.white, + textTheme: TextTheme( + displayLarge: TextStyle( + fontSize: 24.0, + fontWeight: FontWeight.bold, + color: Colors.black, + ), + bodyLarge: TextStyle( + fontSize: 16.0, + color: Colors.black87, + ), + ), +); + +final ThemeData darkTheme = ThemeData( + brightness: Brightness.dark, + primaryColor: Colors.grey[900], + hintColor: Colors.blueAccent, + scaffoldBackgroundColor: Colors.grey[850], + textTheme: TextTheme( + displayLarge: TextStyle( + fontSize: 24.0, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + bodyLarge: TextStyle( + fontSize: 16.0, + color: Colors.white70, + ), + ), +); class ChatPage extends StatefulWidget { const ChatPage({Key? key, required this.converser}) : super(key: key); @@ -24,10 +62,14 @@ class ChatPage extends StatefulWidget { class ChatPageState extends State { List messageList = []; TextEditingController myController = TextEditingController(); + + //initial theme of the system + ThemeMode _themeMode = ThemeMode.system; @override void initState() { super.initState(); + _loadTheme(); } @override @@ -35,6 +77,28 @@ class ChatPageState extends State { super.didChangeDependencies(); } + Future _loadTheme() async { + SharedPreferences prefs = await SharedPreferences.getInstance(); + int? themeIndex = prefs.getInt(themePreferenceKey); + if (themeIndex != null) { + setState(() { + _themeMode = ThemeMode.values[themeIndex]; + }); + } + } + + Future _saveTheme(ThemeMode mode) async { + SharedPreferences prefs = await SharedPreferences.getInstance(); + await prefs.setInt(themePreferenceKey, mode.index); + } + + void _toggleTheme(bool value) { + setState(() { + _themeMode = value ? ThemeMode.dark : ThemeMode.light; + }); + _saveTheme(_themeMode); + } + final ScrollController _scrollController = ScrollController(); @override @@ -64,86 +128,101 @@ class ChatPageState extends State { } groupedMessages[date]!.add(msg); } - - return Scaffold( - appBar: AppBar( - title: Text(widget.converser), - ), - body: Column( - children: [ - Expanded( - child: messageList.isEmpty - ? const Center( - child: Text('No messages yet'), - ) - : ListView.builder( - controller: _scrollController, - padding: const EdgeInsets.all(8), - itemCount: groupedMessages.keys.length, - itemBuilder: (BuildContext context, int index) { - String date = groupedMessages.keys.elementAt(index); - return Column( - children: [ - Center( - child: Padding( - padding: const EdgeInsets.only(top: 10), - child: Text( - date, - style: const TextStyle(fontWeight: FontWeight.bold), + return Theme( + data: ThemeData( + brightness: _themeMode == ThemeMode.dark + ? Brightness.dark + : Brightness.light, + ), + child: + Scaffold( + appBar: AppBar( + title: Text(widget.converser), + actions: [ + Switch( + value: _themeMode == ThemeMode.dark, + onChanged: _toggleTheme, + activeColor: Colors.blueAccent, + inactiveThumbColor: Colors.grey, + ), + ], + ), + body: Column( + children: [ + Expanded( + child: messageList.isEmpty + ? const Center( + child: Text('No messages yet'), + ) + : ListView.builder( + controller: _scrollController, + padding: const EdgeInsets.all(8), + itemCount: groupedMessages.keys.length, + itemBuilder: (BuildContext context, int index) { + String date = groupedMessages.keys.elementAt(index); + return Column( + children: [ + Center( + child: Padding( + padding: const EdgeInsets.only(top: 10), + child: Text( + date, + style: const TextStyle(fontWeight: FontWeight.bold), + ), ), ), - ), - ...groupedMessages[date]!.map((msg) { - String displayMessage = msg.message; - if (Global.myPrivateKey != null) { - RSAPrivateKey privateKey = Global.myPrivateKey!; - dynamic data = jsonDecode(msg.message); - if (data['type'] == 'text') { - Uint8List encryptedBytes = base64Decode(data['data']); - Uint8List decryptedBytes = rsaDecrypt(privateKey, encryptedBytes); - displayMessage = utf8.decode(decryptedBytes); + ...groupedMessages[date]!.map((msg) { + String displayMessage = msg.message; + if (Global.myPrivateKey != null) { + RSAPrivateKey privateKey = Global.myPrivateKey!; + dynamic data = jsonDecode(msg.message); + if (data['type'] == 'text') { + Uint8List encryptedBytes = base64Decode(data['data']); + Uint8List decryptedBytes = rsaDecrypt(privateKey, encryptedBytes); + displayMessage = utf8.decode(decryptedBytes); + } } - } - return Column( - crossAxisAlignment: msg.msgtype == 'sent' ? CrossAxisAlignment.end : CrossAxisAlignment.start, - children: [ - Align( - alignment: msg.msgtype == 'sent' ? Alignment.centerRight : Alignment.centerLeft, - child: Bubble( - padding: const BubbleEdges.all(12), - margin: const BubbleEdges.only(top: 10), - //add shadow - style: BubbleStyle( - elevation: 3, - shadowColor: Colors.black.withOpacity(0.5), - ), - // nip: msg.msgtype == 'sent' ? BubbleNip.rightTop : BubbleNip.leftTop, - radius: const Radius.circular(10), - color: msg.msgtype == 'sent' ? const Color(0xffd1c4e9) : const Color(0xff80DEEA), - child: msg.message.contains('file') ? _buildFileBubble(msg) : Text( - displayMessage, - style: const TextStyle(color: Colors.black87), + return Column( + crossAxisAlignment: msg.msgtype == 'sent' ? CrossAxisAlignment.end : CrossAxisAlignment.start, + children: [ + Align( + alignment: msg.msgtype == 'sent' ? Alignment.centerRight : Alignment.centerLeft, + child: Bubble( + padding: const BubbleEdges.all(12), + margin: const BubbleEdges.only(top: 10), + //add shadow + style: BubbleStyle( + elevation: 3, + shadowColor: Colors.black.withOpacity(0.5), + ), + // nip: msg.msgtype == 'sent' ? BubbleNip.rightTop : BubbleNip.leftTop, + radius: const Radius.circular(10), + color: msg.msgtype == 'sent' ? const Color(0xffd1c4e9) : const Color(0xff80DEEA), + child: msg.message.contains('file') ? _buildFileBubble(msg) : Text( + displayMessage, + style: const TextStyle(color: Colors.black87), + ), ), ), - ), - Padding( - padding: const EdgeInsets.only(top: 2, bottom: 10), - child: Text( - dateFormatter(timeStamp: msg.timestamp), - style: const TextStyle(color: Colors.black54, fontSize: 10), + Padding( + padding: const EdgeInsets.only(top: 2, bottom: 10), + child: Text( + dateFormatter(timeStamp: msg.timestamp), + style: const TextStyle(color: Colors.black54, fontSize: 10), + ), ), - ), - ], - ); - }), - ], - ); - }, + ], + ); + }), + ], + ); + }, + ), ), - ), - MessagePanel(converser: widget.converser), - ], - ), + MessagePanel(converser: widget.converser), + ], + ), + ) ); } diff --git a/lib/pages/home_screen.dart b/lib/pages/home_screen.dart index ea87b16..73cd1f1 100644 --- a/lib/pages/home_screen.dart +++ b/lib/pages/home_screen.dart @@ -4,14 +4,52 @@ import 'chat_list_screen.dart'; import '../classes/global.dart'; import '../p2p/adhoc_housekeeping.dart'; import 'device_list_screen.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import '../database/database_helper.dart'; /// This the home screen. This can also be considered as the -/// main screen of the application. +/// main screen of the application. /// As the app launches and navigates to the HomeScreen from the Profile screen, /// all the processes of message hopping are being initiated from this page. +const String themePreferenceKey = 'themePreference'; + +final ThemeData lightTheme = ThemeData( + brightness: Brightness.light, + primaryColor: Colors.blue, + scaffoldBackgroundColor: Colors.white, + textTheme: TextTheme( + displayLarge: TextStyle( + fontSize: 24.0, + fontWeight: FontWeight.bold, + color: Colors.black, + ), + bodyLarge: TextStyle( + fontSize: 16.0, + color: Colors.black87, + ), + ), +); + +final ThemeData darkTheme = ThemeData( + brightness: Brightness.dark, + primaryColor: Colors.grey[900], + hintColor: Colors.blueAccent, + scaffoldBackgroundColor: Colors.grey[850], + textTheme: TextTheme( + displayLarge: TextStyle( + fontSize: 24.0, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + bodyLarge: TextStyle( + fontSize: 16.0, + color: Colors.white70, + ), + ), +); + class HomeScreen extends StatefulWidget { const HomeScreen({Key? key}) : super(key: key); @@ -22,11 +60,14 @@ class HomeScreen extends StatefulWidget { class _HomeScreenState extends State { bool isLoading = false; + //initial theme of the system + ThemeMode _themeMode = ThemeMode.system; + @override void initState() { super.initState(); - // init(context); refreshMessages(); + _loadTheme(); } /// After reading all the cache, the home screen becomes visible. @@ -53,48 +94,85 @@ class _HomeScreenState extends State { super.dispose(); } + Future _loadTheme() async { + SharedPreferences prefs = await SharedPreferences.getInstance(); + int? themeIndex = prefs.getInt(themePreferenceKey); + if (themeIndex != null) { + setState(() { + _themeMode = ThemeMode.values[themeIndex]; + }); + } + } + + Future _saveTheme(ThemeMode mode) async { + SharedPreferences prefs = await SharedPreferences.getInstance(); + await prefs.setInt(themePreferenceKey, mode.index); + } + + void _toggleTheme(bool value) { + setState(() { + _themeMode = value ? ThemeMode.dark : ThemeMode.light; + }); + _saveTheme(_themeMode); + } + @override Widget build(BuildContext context) { return DefaultTabController( length: 2, - child: Scaffold( - key: Global.scaffoldKey, - appBar: AppBar( - title: const Text("AOSSIE"), - actions: [ - IconButton( - icon: const Icon(Icons.person), - onPressed: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => const Profile( - onLogin: false, + child: Theme( + data: ThemeData( + brightness: _themeMode == ThemeMode.dark + ? Brightness.dark + : Brightness.light, + ), + child: Scaffold( + key: Global.scaffoldKey, + appBar: AppBar( + title: const Text("AOSSIE"), + actions: [ + // Slider toggle button for light and dark themes + //added switch inplace of icon button + Switch( + value: _themeMode == ThemeMode.dark, + onChanged: _toggleTheme, + activeColor: Colors.blueAccent, + inactiveThumbColor: Colors.grey, + ), + IconButton( + icon: const Icon(Icons.person), + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const Profile( + onLogin: false, + ), ), - ), - ); - }, - ), - ], - bottom: const TabBar( - tabs: [ - Tab( - text: "Devices", + ); + }, ), - Tab( - text: "All Chats", + ], + bottom: const TabBar( + tabs: [ + Tab( + text: "Devices", + ), + Tab( + text: "All Chats", + ), + ], + ), + ), + body: const TabBarView( + children: [ + DevicesListScreen( + deviceType: DeviceType.browser, ), + ChatListScreen(), ], ), ), - body: const TabBarView( - children: [ - DevicesListScreen( - deviceType: DeviceType.browser, - ), - ChatListScreen(), - ], - ), ), ); } diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 14cd431..9d4b458 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -9,7 +9,7 @@ import flutter_secure_storage_macos import local_auth_darwin import path_provider_foundation import shared_preferences_foundation -import sqflite +import sqflite_darwin func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) diff --git a/pubspec.yaml b/pubspec.yaml index b3040f7..f189bf4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -32,6 +32,8 @@ dependencies: open_filex: ^4.5.0 permission_handler: ^11.3.1 path_provider: ^2.1.4 + flutter_sound: + lottie: dev_dependencies: flutter_lints: @@ -39,4 +41,6 @@ dev_dependencies: sdk: flutter flutter: + assets: + - assets/audioAnimation.json uses-material-design: true \ No newline at end of file