From 1348a30ecab1ea19eb6e62d8ea67c5a82822c148 Mon Sep 17 00:00:00 2001 From: dikshantbhangala Date: Mon, 30 Dec 2024 15:28:07 +0530 Subject: [PATCH 01/16] done with change --- lib/services/communication_service.dart | 0 macos/Flutter/GeneratedPluginRegistrant.swift | 2 +- .../MainActivity.kt | 0 pubspec.yaml | 1 + 4 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 lib/services/communication_service.dart rename {android/app/src/main/kotlin/com/nankai/openpeerchat_flutter => openpeerchat_flutter}/MainActivity.kt (100%) diff --git a/lib/services/communication_service.dart b/lib/services/communication_service.dart new file mode 100644 index 0000000..e69de29 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/android/app/src/main/kotlin/com/nankai/openpeerchat_flutter/MainActivity.kt b/openpeerchat_flutter/MainActivity.kt similarity index 100% rename from android/app/src/main/kotlin/com/nankai/openpeerchat_flutter/MainActivity.kt rename to openpeerchat_flutter/MainActivity.kt diff --git a/pubspec.yaml b/pubspec.yaml index b3040f7..70a4a2f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -32,6 +32,7 @@ dependencies: open_filex: ^4.5.0 permission_handler: ^11.3.1 path_provider: ^2.1.4 + web_socket_channel: ^3.0.1 dev_dependencies: flutter_lints: From 1ed7a2a3ab247769f78a0cd7c1577c043454e36c Mon Sep 17 00:00:00 2001 From: dikshantbhangala Date: Mon, 30 Dec 2024 15:44:53 +0530 Subject: [PATCH 02/16] done --- lib/classes/global.dart | 2 + lib/pages/chat_page.dart | 147 +++++++++++++----------- lib/pages/profile.dart | 4 +- lib/services/communication_service.dart | 32 ++++++ openpeerchat_flutter/MainActivity.kt | 7 -- 5 files changed, 115 insertions(+), 77 deletions(-) delete mode 100644 openpeerchat_flutter/MainActivity.kt diff --git a/lib/classes/global.dart b/lib/classes/global.dart index 6af0a7b..fb4d39f 100644 --- a/lib/classes/global.dart +++ b/lib/classes/global.dart @@ -28,6 +28,8 @@ class Global extends ChangeNotifier { static Map cache = {}; static final GlobalKey scaffoldKey = GlobalKey(); + static var profileNameStream; + void sentToConversations(Msg msg, String converser, {bool addToTable = true}) { diff --git a/lib/pages/chat_page.dart b/lib/pages/chat_page.dart index 33a2518..d2883ad 100644 --- a/lib/pages/chat_page.dart +++ b/lib/pages/chat_page.dart @@ -13,26 +13,31 @@ import '../components/view_file.dart'; import '../encyption/rsa.dart'; class ChatPage extends StatefulWidget { - const ChatPage({Key? key, required this.converser}) : super(key: key); + String converser; + ChatPage({Key? key, required this.converser}) : super(key: key); + - final String converser; @override - ChatPageState createState() => ChatPageState(); + _ChatPageState createState() => _ChatPageState(); } -class ChatPageState extends State { +class _ChatPageState extends State { List messageList = []; TextEditingController myController = TextEditingController(); @override void initState() { super.initState(); - } - - @override - void didChangeDependencies() { - super.didChangeDependencies(); + // Adding listener to listen for profile name updates + Global.profileNameStream.listen((updatedName) { + setState(() { + if (widget.converser == Global.myName) { + // Update the converser name if it matches the updated profile name + widget.converser = updatedName; + } + }); + }); } final ScrollController _scrollController = ScrollController(); @@ -74,72 +79,78 @@ class ChatPageState extends State { Expanded( child: messageList.isEmpty ? const Center( - child: Text('No messages yet'), - ) + 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); - } - } + controller: _scrollController, + padding: const EdgeInsets.all(8), + itemCount: groupedMessages.keys.length, + itemBuilder: (BuildContext context, int index) { + String date = groupedMessages.keys.elementAt(index); 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), + Center( + child: Padding( + padding: const EdgeInsets.only(top: 10), + child: Text( + date, + style: const TextStyle(fontWeight: FontWeight.bold), ), ), ), - Padding( - padding: const EdgeInsets.only(top: 2, bottom: 10), - child: Text( - dateFormatter(timeStamp: msg.timestamp), - style: const TextStyle(color: Colors.black54, fontSize: 10), - ), - ), + ...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), + style: BubbleStyle( + elevation: 3, + shadowColor: Colors.black.withOpacity(0.5), + ), + 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), + ), + ), + ], + ); + }), ], ); - }), - ], - ); - }, - ), + }, + ), ), MessagePanel(converser: widget.converser), ], @@ -161,7 +172,6 @@ class ChatPageState extends State { color: Colors.black87, ), overflow: TextOverflow.visible, - ), ), IconButton( @@ -180,3 +190,4 @@ String dateFormatter({required String timeStamp}) { String formattedTime = DateFormat('hh:mm aa').format(dateTime); return formattedTime; } + diff --git a/lib/pages/profile.dart b/lib/pages/profile.dart index 6e344e7..1dac65c 100644 --- a/lib/pages/profile.dart +++ b/lib/pages/profile.dart @@ -4,7 +4,7 @@ import 'home_screen.dart'; import 'package:nanoid/nanoid.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../classes/global.dart'; - +import '../services/communication_service.dart'; class Profile extends StatefulWidget { final bool onLogin; @@ -113,7 +113,7 @@ class _ProfileState extends State { // saving the name and id to shared preferences prefs.setString('p_name', myName.text); prefs.setString('p_id', customLengthId); - + CommunicationService.broadcastProfileUpdate(customLengthId, myName.text); // On pressing, move to the home screen navigateToHomeScreen(); }, diff --git a/lib/services/communication_service.dart b/lib/services/communication_service.dart index e69de29..3681a17 100644 --- a/lib/services/communication_service.dart +++ b/lib/services/communication_service.dart @@ -0,0 +1,32 @@ +import 'dart:convert'; +// ignore: depend_on_referenced_packages +import 'package:web_socket_channel/web_socket_channel.dart'; + +class CommunicationService { + static final WebSocketChannel _channel = + WebSocketChannel.connect(Uri.parse('ws://your-websocket-server-url')); + + /// Broadcasts a profile update to all connected peers + static void broadcastProfileUpdate(String userId, String newName) { + final message = { + 'type': 'profile_update', + 'userId': userId, + 'newName': newName, + }; + + _channel.sink.add(jsonEncode(message)); + } + + /// Listens for incoming messages + static void listen(void Function(Map) onMessage) { + _channel.stream.listen((data) { + final decodedData = jsonDecode(data); + onMessage(decodedData); + }); + } + + /// Closes the WebSocket connection + static void closeConnection() { + _channel.sink.close(); + } +} diff --git a/openpeerchat_flutter/MainActivity.kt b/openpeerchat_flutter/MainActivity.kt deleted file mode 100644 index d9d9e99..0000000 --- a/openpeerchat_flutter/MainActivity.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.nankai.openpeerchat_flutter - -import io.flutter.embedding.android.FlutterFragmentActivity - -class MainActivity: FlutterFragmentActivity() { - // ... -} \ No newline at end of file From c669a236a9c69f22165bf8f66a6ae6d4bb9aec77 Mon Sep 17 00:00:00 2001 From: dikshantbhangala Date: Fri, 3 Jan 2025 17:15:20 +0530 Subject: [PATCH 03/16] Added audio recording feature and refactored code --- lib/components/message_panel.dart | 135 +++++++++++++----- lib/encyption/rsa.dart | 69 ++++----- lib/pages/chat_page.dart | 227 +++++++++++++++--------------- pubspec.yaml | 1 + 4 files changed, 250 insertions(+), 182 deletions(-) diff --git a/lib/components/message_panel.dart b/lib/components/message_panel.dart index c5db219..637efbc 100644 --- a/lib/components/message_panel.dart +++ b/lib/components/message_panel.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'dart:io'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_sound/flutter_sound.dart'; // ADDED import 'package:flutter/services.dart'; import 'package:nanoid/nanoid.dart'; import 'package:pointycastle/asymmetric/api.dart'; @@ -13,10 +14,6 @@ import '../database/database_helper.dart'; import '../encyption/rsa.dart'; import 'view_file.dart'; -/// This component is used in the ChatPage. -/// It is the message bar where the message is typed on and sent to -/// connected devices. - class MessagePanel extends StatefulWidget { const MessagePanel({Key? key, required this.converser}) : super(key: key); final String converser; @@ -28,19 +25,41 @@ class MessagePanel extends StatefulWidget { class _MessagePanelState extends State { TextEditingController myController = TextEditingController(); File _selectedFile = File(''); + FlutterSoundRecorder? _recorder; // ADDED + bool _isRecording = false; // ADDED + String? _recordedFilePath; // ADDED + + @override + void initState() { + super.initState(); + _recorder = FlutterSoundRecorder(); // ADDED + _initializeRecorder(); // ADDED + } + + @override + void dispose() { + _recorder?.closeRecorder(); // ADDED + _recorder = null; // ADDED + super.dispose(); + } + + // ADDED: Initialize audio recorder + Future _initializeRecorder() async { + await _recorder?.openRecorder(); + await _recorder?.setSubscriptionDuration(const Duration(milliseconds: 100)); + } @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,6 +68,13 @@ class _MessagePanelState extends State { onPressed: () => _navigateToFilePreviewPage(context), icon: const Icon(Icons.attach_file), ), + IconButton( + onPressed: _toggleRecording, // ADDED + icon: Icon( + _isRecording ? Icons.mic_off : Icons.mic, // ADDED + color: _isRecording ? Colors.red : null, // ADDED + ), + ), IconButton( onPressed: () => _sendMessage(context), icon: const Icon( @@ -67,7 +93,6 @@ class _MessagePanelState extends State { if (myController.text.isEmpty) { return; } - // Encode the message to base64 String data = jsonEncode({ "sender": Global.myName, @@ -95,7 +120,6 @@ class _MessagePanelState extends State { ); RSAPublicKey publicKey = Global.myPublicKey!; - // Encrypt the message Uint8List encryptedMessage = rsaEncrypt( publicKey, Uint8List.fromList(utf8.encode(myController.text))); @@ -110,21 +134,76 @@ class _MessagePanelState extends State { widget.converser, ); - // refreshMessages(); myController.clear(); } - /// This function is used to navigate to the file preview page and check the file size. + // ADDED: Start and stop audio recording + Future _toggleRecording() async { + if (_isRecording) { + final path = await _recorder?.stopRecorder(); + setState(() { + _isRecording = false; + _recordedFilePath = path; + }); + if (path != null) { + _sendAudioMessage(context, path); // Send the recorded audio + } + } else { + await _recorder?.startRecorder( + codec: Codec.aacMP4, + toFile: 'audio_${DateTime.now().millisecondsSinceEpoch}.m4a', + ); + setState(() { + _isRecording = true; + }); + } + } + + // ADDED: Send recorded audio as a message + void _sendAudioMessage(BuildContext context, String filePath) { + var msgId = nanoid(21); + String fileName = filePath.split('/').last; + + String data = jsonEncode({ + "sender": Global.myName, + "type": "audio", + "fileName": fileName, + "filePath": filePath, + }); + + String date = DateTime.now().toUtc().toString(); + Global.cache[msgId] = Payload( + msgId, + Global.myName, + widget.converser, + data, + date, + ); + insertIntoMessageTable( + Payload( + msgId, + Global.myName, + widget.converser, + data, + date, + ), + ); + + Provider.of(context, listen: false).sentToConversations( + Msg(data, "sent", date, msgId), + widget.converser, + ); + } + + // Existing file picker and sender logic void _navigateToFilePreviewPage(BuildContext context) async { - //max size of file is 30 MB double sizeKbs = 0; const int maxSizeKbs = 30 * 1024; FilePickerResult? result = await FilePicker.platform.pickFiles(); - if(result != null) { + if (result != null) { sizeKbs = result.files.single.size / 1024; } - if (sizeKbs > maxSizeKbs) { if (!context.mounted) return; showDialog( @@ -136,10 +215,8 @@ class _MessagePanelState extends State { mainAxisSize: MainAxisSize.min, children: [ ListTile( - //file size in MB title: Text('File Size: ${(sizeKbs / 1024).ceil()} MB'), - subtitle: const Text( - 'File size should not exceed 30 MB'), + subtitle: const Text('File size should not exceed 30 MB'), ), ], ), @@ -157,7 +234,6 @@ class _MessagePanelState extends State { return; } -//this function is used to open the file preview dialog if (result != null) { setState(() { _selectedFile = File(result.files.single.path!); @@ -172,12 +248,11 @@ class _MessagePanelState extends State { mainAxisSize: MainAxisSize.min, children: [ ListTile( - - title: Text('File Name: ${_selectedFile.path - .split('/') - .last}', overflow: TextOverflow.ellipsis,), - subtitle: Text( - 'File Size: ${(sizeKbs / 1024).floor()} MB'), + title: Text( + 'File Name: ${_selectedFile.path.split('/').last}', + overflow: TextOverflow.ellipsis, + ), + subtitle: Text('File Size: ${(sizeKbs / 1024).floor()} MB'), ), ElevatedButton( onPressed: () => FilePreview.openFile(_selectedFile.path), @@ -186,7 +261,6 @@ class _MessagePanelState extends State { ], ), actions: [ - TextButton( onPressed: () { Navigator.of(context).pop(); @@ -195,10 +269,9 @@ class _MessagePanelState extends State { ), IconButton( onPressed: () { - Navigator.pop(context); - _sendFileMessage(context, _selectedFile); - - }, + Navigator.pop(context); + _sendFileMessage(context, _selectedFile); + }, icon: const Icon( Icons.send, ), @@ -210,9 +283,7 @@ class _MessagePanelState extends State { } } - -/// This function is used to send the file message. - void _sendFileMessage(BuildContext context, File file) async{ + void _sendFileMessage(BuildContext context, File file) async { var msgId = nanoid(21); String fileName = _selectedFile.path.split('/').last; @@ -247,7 +318,5 @@ class _MessagePanelState extends State { Msg(data, "sent", date, msgId), widget.converser, ); - } - } diff --git a/lib/encyption/rsa.dart b/lib/encyption/rsa.dart index 042b2f3..82ef802 100644 --- a/lib/encyption/rsa.dart +++ b/lib/encyption/rsa.dart @@ -1,19 +1,17 @@ import 'dart:convert'; import 'dart:typed_data'; import 'package:asn1lib/asn1lib.dart'; -import 'package:pointycastle/src/platform_check/platform_check.dart'; -import "package:pointycastle/export.dart"; +import 'package:pointycastle/export.dart'; +import 'package:pointycastle/random/fortuna_random.dart'; +import 'dart:math'; -AsymmetricKeyPair generateRSAkeyPair( - SecureRandom secureRandom, - {int bitLength = 2048}) { +AsymmetricKeyPair generateRSAkeyPair(SecureRandom secureRandom, {int bitLength = 2048}) { final keyGen = RSAKeyGenerator() ..init(ParametersWithRandom( RSAKeyGeneratorParameters(BigInt.parse('65537'), bitLength, 64), secureRandom)); final pair = keyGen.generateKeyPair(); - final myPublic = pair.publicKey as RSAPublicKey; final myPrivate = pair.privateKey as RSAPrivateKey; @@ -21,53 +19,49 @@ AsymmetricKeyPair generateRSAkeyPair( } SecureRandom exampleSecureRandom() { - final secureRandom = FortunaRandom() - ..seed(KeyParameter( - Platform.instance.platformEntropySource().getBytes(32))); + final secureRandom = FortunaRandom(); + final seedSource = Random.secure(); + final seed = List.generate(32, (_) => seedSource.nextInt(256)); + secureRandom.seed(KeyParameter(Uint8List.fromList(seed))); return secureRandom; } - Uint8List rsaEncrypt(RSAPublicKey myPublic, Uint8List dataToEncrypt) { final encryptor = OAEPEncoding(RSAEngine()) - ..init(true, PublicKeyParameter(myPublic)); // true=encrypt + ..init(true, PublicKeyParameter(myPublic)); return _processInBlocks(encryptor, dataToEncrypt); } Uint8List rsaDecrypt(RSAPrivateKey myPrivate, Uint8List cipherText) { final decryptor = OAEPEncoding(RSAEngine()) - ..init(false, PrivateKeyParameter(myPrivate)); // false=decrypt + ..init(false, PrivateKeyParameter(myPrivate)); return _processInBlocks(decryptor, cipherText); } - Uint8List _processInBlocks(AsymmetricBlockCipher engine, Uint8List input) { - final numBlocks = input.length ~/ engine.inputBlockSize + - ((input.length % engine.inputBlockSize != 0) ? 1 : 0); - - final output = Uint8List(numBlocks * engine.outputBlockSize); + final inputBlockSize = engine.inputBlockSize; + final outputBlockSize = engine.outputBlockSize; + final numBlocks = (input.length / inputBlockSize).ceil(); + final output = Uint8List(numBlocks * outputBlockSize); var inputOffset = 0; var outputOffset = 0; + while (inputOffset < input.length) { - final chunkSize = (inputOffset + engine.inputBlockSize <= input.length) - ? engine.inputBlockSize + final chunkSize = (input.length - inputOffset > inputBlockSize) + ? inputBlockSize : input.length - inputOffset; outputOffset += engine.processBlock( input, inputOffset, chunkSize, output, outputOffset); - inputOffset += chunkSize; } - return (output.length == outputOffset) - ? output - : output.sublist(0, outputOffset); + return output.sublist(0, outputOffset); } - String encodePrivateKeyToPem(RSAPrivateKey privateKey) { final topLevel = ASN1Sequence(); topLevel.add(ASN1Integer(BigInt.from(0))); @@ -93,29 +87,26 @@ String encodePublicKeyToPem(RSAPublicKey publicKey) { return "-----BEGIN PUBLIC KEY-----\r\n$dataBase64\r\n-----END PUBLIC KEY-----"; } -//parsePrivateKeyFromPem RSAPrivateKey parsePrivateKeyFromPem(String pem) { - final data = pem.split(RegExp(r'\r?\n')); - final raw = base64.decode(data.sublist(1, data.length - 1).join('')); + final data = pem.split(RegExp(r'\r?\n')).where((line) => !line.contains('-----')).join(''); + final raw = base64.decode(data); final topLevel = ASN1Sequence.fromBytes(raw); - final n = topLevel.elements[1] as ASN1Integer; - final d = topLevel.elements[3] as ASN1Integer; - final p = topLevel.elements[4] as ASN1Integer; - final q = topLevel.elements[5] as ASN1Integer; + final n = (topLevel.elements[1] as ASN1Integer).valueAsBigInteger; + final d = (topLevel.elements[3] as ASN1Integer).valueAsBigInteger; + final p = (topLevel.elements[4] as ASN1Integer).valueAsBigInteger; + final q = (topLevel.elements[5] as ASN1Integer).valueAsBigInteger; - return RSAPrivateKey( - n.valueAsBigInteger, d.valueAsBigInteger, p.valueAsBigInteger, q.valueAsBigInteger); + return RSAPrivateKey(n, d, p, q); } RSAPublicKey parsePublicKeyFromPem(String pem) { - final data = pem.split(RegExp(r'\r?\n')); - final raw = base64.decode(data.sublist(1, data.length - 1).join('')); + final data = pem.split(RegExp(r'\r?\n')).where((line) => !line.contains('-----')).join(''); + final raw = base64.decode(data); final topLevel = ASN1Sequence.fromBytes(raw); - final modulus = topLevel.elements[0] as ASN1Integer; - final exponent = topLevel.elements[1] as ASN1Integer; + final modulus = (topLevel.elements[0] as ASN1Integer).valueAsBigInteger; + final exponent = (topLevel.elements[1] as ASN1Integer).valueAsBigInteger; - return RSAPublicKey(modulus.valueAsBigInteger, exponent.valueAsBigInteger); + return RSAPublicKey(modulus, exponent); } - diff --git a/lib/pages/chat_page.dart b/lib/pages/chat_page.dart index d2883ad..9eb4600 100644 --- a/lib/pages/chat_page.dart +++ b/lib/pages/chat_page.dart @@ -1,5 +1,4 @@ import 'dart:typed_data'; - import 'package:bubble/bubble.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; @@ -10,65 +9,53 @@ import '../classes/global.dart'; import 'dart:convert'; import 'package:pointycastle/asymmetric/api.dart'; import '../components/view_file.dart'; -import '../encyption/rsa.dart'; +import '../encryption/rsa.dart'; class ChatPage extends StatefulWidget { String converser; - ChatPage({Key? key, required this.converser}) : super(key: key); - - + ChatPage({Key? key, required this.converser}) : super(key: key); @override _ChatPageState createState() => _ChatPageState(); } class _ChatPageState extends State { + final ScrollController _scrollController = ScrollController(); List messageList = []; - TextEditingController myController = TextEditingController(); @override void initState() { super.initState(); - // Adding listener to listen for profile name updates + _subscribeToProfileUpdates(); + } + + void _subscribeToProfileUpdates() { Global.profileNameStream.listen((updatedName) { - setState(() { - if (widget.converser == Global.myName) { - // Update the converser name if it matches the updated profile name + if (widget.converser == Global.myName) { + setState(() { widget.converser = updatedName; - } - }); + }); + } }); } - final ScrollController _scrollController = ScrollController(); - - @override - Widget build(BuildContext context) { - if (Provider.of(context).conversations[widget.converser] != null) { - messageList = []; - Provider.of(context) - .conversations[widget.converser]! - .forEach((key, value) { - messageList.add(value); - }); - + void _scrollToBottom() { + WidgetsBinding.instance.addPostFrameCallback((_) { if (_scrollController.hasClients) { _scrollController.animateTo( - _scrollController.position.maxScrollExtent + 50, + _scrollController.position.maxScrollExtent, duration: const Duration(milliseconds: 300), curve: Curves.easeOut, ); } - } + }); + } - Map> groupedMessages = {}; - for (var msg in messageList) { - String date = DateFormat('dd/MM/yyyy').format(DateTime.parse(msg.timestamp)); - if (groupedMessages[date] == null) { - groupedMessages[date] = []; - } - groupedMessages[date]!.add(msg); - } + @override + Widget build(BuildContext context) { + messageList = _getMessageList(context); + + Map> groupedMessages = _groupMessagesByDate(messageList); return Scaffold( appBar: AppBar( @@ -78,77 +65,14 @@ class _ChatPageState extends State { children: [ Expanded( child: messageList.isEmpty - ? const Center( - child: Text('No messages yet'), - ) + ? 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) { + itemBuilder: (context, 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); - } - } - 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), - style: BubbleStyle( - elevation: 3, - shadowColor: Colors.black.withOpacity(0.5), - ), - 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), - ), - ), - ], - ); - }), - ], - ); + return _buildMessageGroup(date, groupedMessages[date]!); }, ), ), @@ -158,6 +82,83 @@ class _ChatPageState extends State { ); } + List _getMessageList(BuildContext context) { + var conversation = Provider.of(context).conversations[widget.converser]; + if (conversation == null) return []; + return conversation.values.toList(); + } + + Map> _groupMessagesByDate(List messages) { + Map> groupedMessages = {}; + for (var msg in messages) { + String date = DateFormat('dd/MM/yyyy').format(DateTime.parse(msg.timestamp)); + groupedMessages.putIfAbsent(date, () => []).add(msg); + } + return groupedMessages; + } + + Widget _buildMessageGroup(String date, List messages) { + return Column( + children: [ + _buildDateHeader(date), + ...messages.map((msg) => _buildMessageBubble(msg)), + ], + ); + } + + Widget _buildDateHeader(String date) { + return Center( + child: Padding( + padding: const EdgeInsets.only(top: 10), + child: Text( + date, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ), + ); + } + + Widget _buildMessageBubble(Msg msg) { + String displayMessage = msg.message; + if (msg.msgtype == 'text' && Global.myPrivateKey != null) { + displayMessage = _decryptMessage(msg.message); + } + 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), + style: BubbleStyle( + elevation: 3, + shadowColor: Colors.black.withOpacity(0.5), + ), + 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), + ), + ), + ], + ); + } + Widget _buildFileBubble(Msg msg) { dynamic data = jsonDecode(msg.message); String fileName = data['fileName']; @@ -168,26 +169,32 @@ class _ChatPageState extends State { Flexible( child: Text( fileName, - style: const TextStyle( - color: Colors.black87, - ), - overflow: TextOverflow.visible, + style: const TextStyle(color: Colors.black87), + overflow: TextOverflow.ellipsis, ), ), IconButton( icon: const Icon(Icons.file_open, color: Colors.black87), - onPressed: () { - FilePreview.openFile(filePath); - }, + onPressed: () => FilePreview.openFile(filePath), ), ], ); } + + String _decryptMessage(String message) { + try { + RSAPrivateKey privateKey = Global.myPrivateKey!; + dynamic data = jsonDecode(message); + Uint8List encryptedBytes = base64Decode(data['data']); + Uint8List decryptedBytes = rsaDecrypt(privateKey, encryptedBytes); + return utf8.decode(decryptedBytes); + } catch (e) { + return "[Error decrypting message]"; + } + } } String dateFormatter({required String timeStamp}) { DateTime dateTime = DateTime.parse(timeStamp); - String formattedTime = DateFormat('hh:mm aa').format(dateTime); - return formattedTime; + return DateFormat('hh:mm aa').format(dateTime); } - diff --git a/pubspec.yaml b/pubspec.yaml index 70a4a2f..b67840c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -33,6 +33,7 @@ dependencies: permission_handler: ^11.3.1 path_provider: ^2.1.4 web_socket_channel: ^3.0.1 + flutter_sound: ^9.6.0 dev_dependencies: flutter_lints: From d3076aef53f63dff69a3afba3e23da8a82ef98ba Mon Sep 17 00:00:00 2001 From: bhavik-mangla <92915999+bhavik-mangla@users.noreply.github.com> Date: Mon, 6 Jan 2025 14:36:09 +0530 Subject: [PATCH 04/16] Create Contribution_Guidelines.md --- Contribution_Guidelines.md | 40 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 Contribution_Guidelines.md diff --git a/Contribution_Guidelines.md b/Contribution_Guidelines.md new file mode 100644 index 0000000..b36e4b6 --- /dev/null +++ b/Contribution_Guidelines.md @@ -0,0 +1,40 @@ +## Steps to Set Up Open Peer Chat + +1. **Fork the Repository** + - Navigate to the [Open Peer Chat GitHub Repository](https://github.com/AOSSIE-Org/OpenPeerChat-flutter). + - Click the **Fork** button in the top-right corner to create your own copy of the repository. + +2. **Clone Your Forked Repository** + ```bash + git clone https://github.com/YOUR_USERNAME/OpenPeerChat-flutter.git + cd OpenPeerChat-flutter + ``` + +3. **Make Your Changes** + - Modify the code to implement the required feature or resolve the issue. + +4. **Commit Your Changes** + - Stage your changes and commit them with a meaningful message. + ```bash + git add . + git commit -m "Add: Brief description of your update" + ``` + +5. **Push Your Changes** + - Push your changes to your forked repository. + ```bash + git push origin main + ``` + +6. **Create a Pull Request** + - Go to your forked repository on GitHub. + - Click the **Compare & Pull Request** button. + - Add a detailed description of the changes you made. + - Include a link to a demo video showcasing the feature you added or the issue you resolved. + - Submit the pull request for review. + +### Additional Notes +- Contributors should make changes directly to the `main` branch of their forked repository. +- Ensure that your code adheres to the project’s coding standards and passes all necessary tests before creating a pull request. +- Provide a clear and concise description of your changes in the pull request, along with screenshots or video demonstrations if applicable. +- Keep your forked repository up-to-date with the latest changes from the `main` branch to avoid conflicts. From 3dc9a42fb2547df578dd0b6316a6f8b905726ec6 Mon Sep 17 00:00:00 2001 From: bhavik-mangla <92915999+bhavik-mangla@users.noreply.github.com> Date: Mon, 6 Jan 2025 14:39:11 +0530 Subject: [PATCH 05/16] Update README.md --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index a8a0fc9..b63c69a 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,11 @@ # Peer-to-Peer Messaging Application +# LATEST Updates (GSOC 2024) - +https://github.com/AOSSIE-Org/OpenPeerChat-flutter/blob/main/GSOC/2024/Bhavik_Mangla.md + +# Contribution Guidelines - +https://github.com/AOSSIE-Org/OpenPeerChat-flutter/blob/main/Contribution_Guidelines.md + GSoC pitch 2021. # Chosen Idea: A message sending/relaying messages to nearby devices until the destination is reached, instead of relying on a central server. GPS positioning could be used to route messages along the shortest path. Right now, despite the use of end-to-end encryption, our best and most popular messaging apps still rely on central servers to intermediate the communication. This has disadvantages such as: From c15c74e27266a06b74781d1b0c39b5ae1eaac45d Mon Sep 17 00:00:00 2001 From: Bhavik Mangla Date: Sat, 11 Jan 2025 23:03:54 +0530 Subject: [PATCH 06/16] Add Voice Message Recording and Playback Issue #26 --- android/app/src/main/AndroidManifest.xml | 34 ++- ios/Podfile | 48 ++++ ios/Runner.xcodeproj/project.pbxproj | 98 ++++---- .../xcshareddata/xcschemes/Runner.xcscheme | 1 + ios/Runner/Info.plist | 35 +-- lib/classes/global.dart | 21 +- lib/components/audio_service.dart | 222 ++++++++++++++++++ lib/components/message_panel.dart | 165 +++++++++++-- lib/p2p/adhoc_housekeeping.dart | 2 +- lib/pages/chat_page.dart | 85 +++++++ linux/flutter/generated_plugin_registrant.cc | 8 + linux/flutter/generated_plugins.cmake | 2 + macos/Flutter/GeneratedPluginRegistrant.swift | 10 +- pubspec.yaml | 4 + .../flutter/generated_plugin_registrant.cc | 6 + windows/flutter/generated_plugins.cmake | 2 + 16 files changed, 637 insertions(+), 106 deletions(-) create mode 100644 lib/components/audio_service.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index eb48cb0..932a54b 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,18 +1,22 @@ - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; @@ -214,26 +236,21 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; }; - 6286E038E4D2E640F9A73383 /* [CP] Check Pods Manifest.lock */ = { + 623560887A14F11A592E14A2 /* [CP] Copy Pods Resources */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; + name = "[CP] Copy Pods Resources"; outputFileListPaths = ( - ); - outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; showEnvVarsInLog = 0; }; 9740EEB61CF901F6004384FC /* Run Script */ = { @@ -251,7 +268,7 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; }; - D0A49172F0D59151F50A8FA4 /* [CP] Embed Pods Frameworks */ = { + BC561ADEFDA59CE89BC36ABB /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -268,23 +285,6 @@ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; showEnvVarsInLog = 0; }; - FFA8B1284F3221840EE75321 /* [CP] Copy Pods Resources */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", - ); - name = "[CP] Copy Pods Resources"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; - showEnvVarsInLog = 0; - }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index e67b280..4f74653 100644 --- a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -50,6 +50,7 @@ ignoresPersistentStateOnLaunch = "NO" debugDocumentVersioning = "YES" debugServiceExtension = "internal" + enableGPUValidationMode = "1" allowLocationSimulation = "YES"> diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index d9e3dba..9540c38 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -2,15 +2,8 @@ - NSBonjourServices - - _mp-connection._tcp - - UIRequiresPersistentWiFi + CADisableMinimumFrameDurationOnPhone - NSBluetoothAlwaysUsageDescription - nearby connections - CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleExecutable @@ -31,10 +24,30 @@ $(FLUTTER_BUILD_NUMBER) LSRequiresIPhoneOS + NSBluetoothAlwaysUsageDescription + nearby connections + NSBonjourServices + + _mp-connection._tcp + + NSFaceIDUsageDescription + Why is my app authenticating using face id? + NSMicrophoneUsageDescription + OpenPeerChat requires microphone access to record voice messages + NSPhotoLibraryUsageDescription + OpenPeerChat needs access to save voice messages + UIApplicationSupportsIndirectInputEvents + + UIBackgroundModes + + audio + UILaunchStoryboardName LaunchScreen UIMainStoryboardFile Main + UIRequiresPersistentWiFi + UISupportedInterfaceOrientations UIInterfaceOrientationPortrait @@ -50,11 +63,5 @@ UIViewControllerBasedStatusBarAppearance - CADisableMinimumFrameDurationOnPhone - - UIApplicationSupportsIndirectInputEvents - - NSFaceIDUsageDescription - Why is my app authenticating using face id? diff --git a/lib/classes/global.dart b/lib/classes/global.dart index 6af0a7b..d7cd71d 100644 --- a/lib/classes/global.dart +++ b/lib/classes/global.dart @@ -50,9 +50,12 @@ class Global extends ChangeNotifier { } //file decoding and saving - if(message['type'] == 'file') { - String filePath = await decodeAndStoreFile( - message['data'], message['fileName']); + if (message['type'] == 'voice' || message['type'] == 'file') { + final String filePath = await decodeAndStoreFile( + message['data'], + message['fileName'], + isVoice: message['type'] == 'voice', + ); conversations.putIfAbsent(sender, () => {}); if (!conversations[sender]!.containsKey(decodedMessage['id'])) { decodedMessage['message'] = json.encode({ @@ -81,7 +84,7 @@ class Global extends ChangeNotifier { } } - Future decodeAndStoreFile(String encodedFile, String fileName) async { + Future decodeAndStoreFile(String encodedFile, String fileName, {bool isVoice = false}) async { Uint8List fileBytes = base64.decode(encodedFile); //to send files encrypted using RSA @@ -94,9 +97,17 @@ class Global extends ChangeNotifier { else { documents = await getApplicationDocumentsDirectory(); } + PermissionStatus status = await Permission.storage.request(); + + final String subDir = isVoice ? 'voice_messages' : 'files'; if (status.isGranted) { - final path = '${documents.path}/$fileName'; + + final Directory finalDir = Directory('${documents.path}/$subDir'); + if (!await finalDir.exists()) { + await finalDir.create(recursive: true); + } + final path ='${finalDir.path}/$fileName'; File(path).writeAsBytes(fileBytes); if (kDebugMode) { print("File saved at: $path"); diff --git a/lib/components/audio_service.dart b/lib/components/audio_service.dart new file mode 100644 index 0000000..8dcd41b --- /dev/null +++ b/lib/components/audio_service.dart @@ -0,0 +1,222 @@ +// lib/services/audio_service.dart +import 'dart:io'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:just_audio/just_audio.dart'; +import 'package:record/record.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:permission_handler/permission_handler.dart'; + +class RecordingPermissionException implements Exception { + final String message; + RecordingPermissionException(this.message); + + @override + String toString() => 'RecordingPermissionException: $message'; +} + +class PermissionService { + static Future handleMicrophonePermission(BuildContext context) async { + if (Platform.isIOS) { + final status = await Permission.microphone.status; + debugPrint('iOS Microphone Permission Status: $status'); + + if (status.isDenied) { + final result = await Permission.microphone.request(); + debugPrint('iOS Permission Request Result: $result'); + return result.isGranted; + } + + if (status.isPermanentlyDenied) { + if (context.mounted) { + await _showiOSSettingsDialog(context); + } + return false; + } + + return status.isGranted; + } + + return true; + } + + static Future _showiOSSettingsDialog(BuildContext context) async { + return showDialog( + context: context, + barrierDismissible: false, + builder: (BuildContext context) { + return CupertinoAlertDialog( + title: const Text('Microphone Access Required'), + content: const Text( + 'OpenPeerChat needs access to your microphone to record voice messages.\n\n' + 'Please enable microphone access in Settings.' + ), + actions: [ + CupertinoDialogAction( + child: const Text('Cancel'), + onPressed: () => Navigator.of(context).pop(), + ), + CupertinoDialogAction( + isDefaultAction: true, + child: const Text('Settings'), + onPressed: () { + Navigator.of(context).pop(); + openAppSettings(); + }, + ), + ], + ); + }, + ); + } +} + +class AudioService { + static final AudioService _instance = AudioService._internal(); + factory AudioService() => _instance; + AudioService._internal(); + + final Record _recorder = Record(); + final AudioPlayer _player = AudioPlayer(); + bool _isRecorderInitialized = false; + bool _isRecording = false; + String? _currentRecordingPath; + + // Getters + bool get isRecording => _isRecording; + String? get currentRecordingPath => _currentRecordingPath; + + Future _requestPermissions() async { + try { + final permissions = [Permission.microphone]; + if (Platform.isAndroid) { + permissions.add(Permission.storage); + } + + debugPrint('Requesting permissions: $permissions'); + final statuses = await permissions.request(); + + bool allGranted = true; + statuses.forEach((permission, status) { + debugPrint('Permission $permission status: $status'); + if (status != PermissionStatus.granted) { + allGranted = false; + } + }); + + return allGranted; + } catch (e) { + debugPrint('Permission request error: $e'); + return false; + } + } + + Future initRecorder() async { + if (_isRecorderInitialized) return; + + try { + debugPrint('Initializing recorder...'); + bool permissionsGranted = await _requestPermissions(); + + if (!permissionsGranted) { + throw RecordingPermissionException( + 'Required permissions were not granted. Please enable microphone access in your device settings.' + ); + } + + if (!await _recorder.hasPermission()) { + throw RecordingPermissionException( + 'Microphone permission not available. Please check your device settings.' + ); + } + + _isRecorderInitialized = true; + debugPrint('Recorder initialized successfully'); + } catch (e) { + _isRecorderInitialized = false; + debugPrint('Recorder initialization failed: $e'); + throw RecordingPermissionException(e.toString()); + } + } + + Future startRecording() async { + if (_isRecording) { + throw RecordingPermissionException('Already recording'); + } + + try { + if (!_isRecorderInitialized) { + await initRecorder(); + } + + Directory tempDir = await getTemporaryDirectory(); + _currentRecordingPath = '${tempDir.path}/voice_${DateTime.now().millisecondsSinceEpoch}.m4a'; + + await _recorder.start( + path: _currentRecordingPath!, + encoder: AudioEncoder.aacLc, + bitRate: 128000, + samplingRate: 44100, + ); + + _isRecording = true; + debugPrint('Started recording at: $_currentRecordingPath'); + return _currentRecordingPath!; + } catch (e) { + _isRecording = false; + _currentRecordingPath = null; + debugPrint('Recording failed: $e'); + throw RecordingPermissionException('Failed to start recording: $e'); + } + } + + Future stopRecording() async { + if (!_isRecording) return null; + + try { + await _recorder.stop(); + _isRecording = false; + final recordedPath = _currentRecordingPath; + _currentRecordingPath = null; + debugPrint('Stopped recording. File saved at: $recordedPath'); + return recordedPath; + } catch (e) { + debugPrint('Failed to stop recording: $e'); + throw RecordingPermissionException('Failed to stop recording: $e'); + } + } + + Future playRecording(String path) async { + try { + await _player.setFilePath(path); + await _player.play(); + } catch (e) { + debugPrint('Playback failed: $e'); + throw RecordingPermissionException('Failed to play recording: $e'); + } + } + + Future stopPlaying() async { + try { + await _player.stop(); + } catch (e) { + debugPrint('Failed to stop playback: $e'); + throw RecordingPermissionException('Failed to stop playback: $e'); + } + } + + Future dispose() async { + try { + if (_isRecording) { + await stopRecording(); + } + await _recorder.dispose(); + await _player.dispose(); + _isRecorderInitialized = false; + debugPrint('Audio service disposed successfully'); + } catch (e) { + debugPrint('Failed to dispose audio service: $e'); + throw RecordingPermissionException('Failed to dispose recorder: $e'); + } + } +} \ No newline at end of file diff --git a/lib/components/message_panel.dart b/lib/components/message_panel.dart index c5db219..b6d5e49 100644 --- a/lib/components/message_panel.dart +++ b/lib/components/message_panel.dart @@ -4,6 +4,7 @@ import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:nanoid/nanoid.dart'; +import 'package:permission_handler/permission_handler.dart'; import 'package:pointycastle/asymmetric/api.dart'; import 'package:provider/provider.dart'; import '../classes/global.dart'; @@ -11,6 +12,7 @@ import '../classes/msg.dart'; import '../classes/payload.dart'; import '../database/database_helper.dart'; import '../encyption/rsa.dart'; +import 'audio_service.dart'; import 'view_file.dart'; /// This component is used in the ChatPage. @@ -28,36 +30,127 @@ class MessagePanel extends StatefulWidget { class _MessagePanelState extends State { TextEditingController myController = TextEditingController(); File _selectedFile = File(''); + late final AudioService _audioService; + bool _isRecording = false; + String? _currentRecordingPath; + + @override + void initState() { + super.initState(); + _audioService = AudioService(); + _initializeAudio(); + } + + Future _initializeAudio() async { + try { + await _audioService.initRecorder(); + } catch (e) { + // Show error dialog or snackbar to user + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(e.toString()), + backgroundColor: Colors.red, + action: SnackBarAction( + label: 'Settings', + onPressed: () { + openAppSettings(); // From permission_handler package + }, + ), + ), + ); + } + } + + Future _handleVoiceRecordingStart() async { + try { + _currentRecordingPath = await _audioService.startRecording(); + setState(() => _isRecording = true); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to start recording: $e')), + ); + } + } + } + + Future _handleVoiceRecordingEnd() async { + if (!_isRecording) return; + + try { + await _audioService.stopRecording(); + setState(() => _isRecording = false); + if (_currentRecordingPath != null) { + _sendVoiceMessage(File(_currentRecordingPath!)); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to stop recording: $e')), + ); + } + } + } + + + @override + void dispose() { + myController.dispose(); + _audioService.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { - return Padding( + return Container( 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 ', - suffixIcon: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - onPressed: () => _navigateToFilePreviewPage(context), - icon: const Icon(Icons.attach_file), + decoration: BoxDecoration( + color: Theme.of(context).scaffoldBackgroundColor, + boxShadow: [ + BoxShadow( + offset: const Offset(0, -1), + blurRadius: 5, + color: Colors.black.withOpacity(0.1), + ), + ], + ), + child: Row( + children: [ + Expanded( + child: TextField( + controller: myController, + maxLines: null, + decoration: const InputDecoration( + hintText: 'Type a message', + border: InputBorder.none, ), - IconButton( - onPressed: () => _sendMessage(context), - icon: const Icon( - Icons.send, - ), + ), + ), + IconButton( + icon: const Icon(Icons.attach_file), + onPressed: () => _navigateToFilePreviewPage(context), + ), + GestureDetector( + onLongPressStart: (_) => _handleVoiceRecordingStart(), + onLongPressEnd: (_) => _handleVoiceRecordingEnd(), + child: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: _isRecording ? Colors.red.withOpacity(0.1) : null, + shape: BoxShape.circle, ), - ], + child: Icon( + _isRecording ? Icons.mic : Icons.mic_none, + color: _isRecording ? Colors.red : null, + ), + ), ), - ), + IconButton( + icon: const Icon(Icons.send), + onPressed: () => _sendMessage(context), + ), + ], ), ); } @@ -211,7 +304,31 @@ class _MessagePanelState extends State { } -/// This function is used to send the file message. + void _sendVoiceMessage(File audioFile) async { + final String msgId = nanoid(21); + final String fileName = 'voice_${DateTime.now().millisecondsSinceEpoch}.aac'; + + final String data = jsonEncode({ + "sender": Global.myName, + "type": "voice", + "fileName": fileName, + "filePath": audioFile.path, + }); + + final String date = DateTime.now().toUtc().toString(); + final payload = Payload(msgId, Global.myName, widget.converser, data, date); + + Global.cache[msgId] = payload; + insertIntoMessageTable(payload); + + if (!mounted) return; + Provider.of(context, listen: false).sentToConversations( + Msg(data, "sent", date, msgId), + widget.converser, + ); + } + + /// This function is used to send the file message. void _sendFileMessage(BuildContext context, File file) async{ var msgId = nanoid(21); diff --git a/lib/p2p/adhoc_housekeeping.dart b/lib/p2p/adhoc_housekeeping.dart index 77b0ffe..87e9dbb 100644 --- a/lib/p2p/adhoc_housekeeping.dart +++ b/lib/p2p/adhoc_housekeeping.dart @@ -144,7 +144,7 @@ void broadcast(BuildContext context) async { "data": encodedMessage, }; } - else if (message['type'] == 'file') { + else if (message['type'] == 'voice' || message['type'] == 'file') { File file = File(message['filePath']); Uint8List encryptedBytes = await file.readAsBytes(); diff --git a/lib/pages/chat_page.dart b/lib/pages/chat_page.dart index 33a2518..1d0b7e6 100644 --- a/lib/pages/chat_page.dart +++ b/lib/pages/chat_page.dart @@ -11,6 +11,7 @@ import 'dart:convert'; import 'package:pointycastle/asymmetric/api.dart'; import '../components/view_file.dart'; import '../encyption/rsa.dart'; +import 'package:audioplayers/audioplayers.dart'; class ChatPage extends StatefulWidget { const ChatPage({Key? key, required this.converser}) : super(key: key); @@ -24,11 +25,21 @@ class ChatPage extends StatefulWidget { class ChatPageState extends State { List messageList = []; TextEditingController myController = TextEditingController(); + final AudioPlayer _audioPlayer = AudioPlayer(); + String? _currentlyPlayingId; + bool _isPlaying = false; + @override void initState() { super.initState(); } + @override + void dispose() { + _audioPlayer.dispose(); + super.dispose(); + } + @override void didChangeDependencies() { @@ -37,6 +48,7 @@ class ChatPageState extends State { final ScrollController _scrollController = ScrollController(); + @override Widget build(BuildContext context) { if (Provider.of(context).conversations[widget.converser] != null) { @@ -146,11 +158,84 @@ class ChatPageState extends State { ), ); } + Widget _buildVoiceMessageBubble(Msg msg) { + final data = jsonDecode(msg.message); + final bool isCurrentlyPlaying = _currentlyPlayingId == msg.id; + + return Container( + constraints: const BoxConstraints(maxWidth: 250), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: Icon( + isCurrentlyPlaying && _isPlaying ? Icons.pause : Icons.play_arrow, + color: Colors.black87, + ), + onPressed: () => _handleVoicePlayback(msg), + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Voice Message', + style: TextStyle( + color: Colors.black87, + fontWeight: FontWeight.w500, + ), + ), + if (isCurrentlyPlaying) + const LinearProgressIndicator( + backgroundColor: Colors.grey, + valueColor: AlwaysStoppedAnimation(Colors.blue), + ), + ], + ), + ), + ], + ), + ); + } + + Future _handleVoicePlayback(Msg msg) async { + final data = jsonDecode(msg.message); + final String filePath = data['filePath']; + + if (_currentlyPlayingId == msg.id && _isPlaying) { + await _audioPlayer.pause(); + setState(() => _isPlaying = false); + } else { + if (_currentlyPlayingId != msg.id) { + await _audioPlayer.stop(); + await _audioPlayer.play(DeviceFileSource(filePath)); + } else { + await _audioPlayer.resume(); + } + setState(() { + _currentlyPlayingId = msg.id; + _isPlaying = true; + }); + } + + _audioPlayer.onPlayerComplete.listen((_) { + if (mounted) { + setState(() { + _currentlyPlayingId = null; + _isPlaying = false; + }); + } + }); + } Widget _buildFileBubble(Msg msg) { dynamic data = jsonDecode(msg.message); + if (data['type'] == 'voice') { + return _buildVoiceMessageBubble(msg); + } String fileName = data['fileName']; String filePath = data['filePath']; + return Row( mainAxisSize: MainAxisSize.min, children: [ diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index d0e7f79..81e6606 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,10 +6,18 @@ #include "generated_plugin_registrant.h" +#include #include +#include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) audioplayers_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "AudioplayersLinuxPlugin"); + audioplayers_linux_plugin_register_with_registrar(audioplayers_linux_registrar); g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); + g_autoptr(FlPluginRegistrar) record_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "RecordLinuxPlugin"); + record_linux_plugin_register_with_registrar(record_linux_registrar); } diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index b29e9ba..db399c1 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,7 +3,9 @@ # list(APPEND FLUTTER_PLUGIN_LIST + audioplayers_linux flutter_secure_storage_linux + record_linux ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 14cd431..ee7c3f7 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,16 +5,24 @@ import FlutterMacOS import Foundation +import audio_session +import audioplayers_darwin import flutter_secure_storage_macos +import just_audio import local_auth_darwin import path_provider_foundation +import record_macos import shared_preferences_foundation -import sqflite +import sqflite_darwin func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + AudioSessionPlugin.register(with: registry.registrar(forPlugin: "AudioSessionPlugin")) + AudioplayersDarwinPlugin.register(with: registry.registrar(forPlugin: "AudioplayersDarwinPlugin")) FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) + JustAudioPlugin.register(with: registry.registrar(forPlugin: "JustAudioPlugin")) FLALocalAuthPlugin.register(with: registry.registrar(forPlugin: "FLALocalAuthPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) + RecordMacosPlugin.register(with: registry.registrar(forPlugin: "RecordMacosPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) } diff --git a/pubspec.yaml b/pubspec.yaml index b3040f7..a69ac06 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -32,6 +32,10 @@ dependencies: open_filex: ^4.5.0 permission_handler: ^11.3.1 path_provider: ^2.1.4 + just_audio: ^0.9.42 + record: ^4.4.4 + audioplayers: ^6.1.0 + dev_dependencies: flutter_lints: diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 16383da..5722166 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,15 +6,21 @@ #include "generated_plugin_registrant.h" +#include #include #include #include +#include void RegisterPlugins(flutter::PluginRegistry* registry) { + AudioplayersWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("AudioplayersWindowsPlugin")); FlutterSecureStorageWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); LocalAuthPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("LocalAuthPlugin")); PermissionHandlerWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); + RecordWindowsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("RecordWindowsPluginCApi")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 7badb8c..7de9747 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,9 +3,11 @@ # list(APPEND FLUTTER_PLUGIN_LIST + audioplayers_windows flutter_secure_storage_windows local_auth_windows permission_handler_windows + record_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST From 26243ec4e1ca6e11d41ee8f2181d701308a98bd6 Mon Sep 17 00:00:00 2001 From: khushi-hura Date: Mon, 13 Jan 2025 02:20:39 +0530 Subject: [PATCH 07/16] Fixes issue #28 --- android/app/build.gradle | 2 +- android/app/src/main/AndroidManifest.xml | 33 ++++----- lib/classes/themeProvider.dart | 12 ++++ lib/main.dart | 18 +++-- lib/pages/profile.dart | 87 +++++++++++++++++++++++- 5 files changed, 126 insertions(+), 26 deletions(-) create mode 100644 lib/classes/themeProvider.dart 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 932a54b..f447eed 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,22 +1,21 @@ - - - - - - - - - - - + + + + + + + + + + - - - + + + - - themeMode; + + void toggleTheme(bool isDarkMode) { + themeMode = isDarkMode ? ThemeMode.dark : ThemeMode.light; + notifyListeners(); + } +} \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index 67ce4fc..bf66331 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -7,6 +7,7 @@ import 'package:provider/provider.dart'; import 'classes/global.dart'; import 'encyption/key_storage.dart'; import 'encyption/rsa.dart'; +import 'classes/themeProvider.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -37,6 +38,7 @@ void main() async { ChangeNotifierProvider( create: (_) => Global(), ), + ChangeNotifierProvider(create: (_) => ThemeProvider()), ], child: const MyApp(), ), @@ -48,10 +50,18 @@ class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { - return const MaterialApp( - debugShowCheckedModeBanner: false, - onGenerateRoute: generateRoute, - initialRoute: '/', + final themeProvider = Provider.of(context); + return Consumer( + builder: (context, themeProvider, child) { + return MaterialApp( + theme: lightTheme, + darkTheme: darkTheme, + themeMode: themeProvider.themeMode, + debugShowCheckedModeBanner: false, + onGenerateRoute: generateRoute, + initialRoute: '/', + ); + } ); } } diff --git a/lib/pages/profile.dart b/lib/pages/profile.dart index 6e344e7..417c371 100644 --- a/lib/pages/profile.dart +++ b/lib/pages/profile.dart @@ -1,10 +1,49 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter_nearby_connections_example/classes/themeProvider.dart'; +import 'package:provider/provider.dart'; import 'home_screen.dart'; import 'package:nanoid/nanoid.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../classes/global.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 Profile extends StatefulWidget { final bool onLogin; @@ -14,6 +53,10 @@ class Profile extends StatefulWidget { } class _ProfileState extends State { + + //initial theme of the system + ThemeMode _themeMode = ThemeMode.light; + // TextEditingController for the name of the user TextEditingController myName = TextEditingController(); @@ -69,13 +112,36 @@ class _ProfileState extends State { @override void initState() { super.initState(); - + _loadTheme(); // At the launch we are fetching details using the getDetails function getDetails(); } + 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) { + final themeProvider = Provider.of(context); return Scaffold( appBar: AppBar( title: const Text( @@ -107,6 +173,25 @@ class _ProfileState extends State { }, ), ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Text( + 'Switch to dark theme', + style: const TextStyle( + fontSize: 20, + ), + ), + Switch( + value: themeProvider.themeMode == ThemeMode.dark, + onChanged: (value) { + themeProvider.toggleTheme(value); + }, + activeColor: Colors.blueAccent, + inactiveThumbColor: Colors.grey, + ), + ], + ), ElevatedButton( onPressed: () async { final prefs = await SharedPreferences.getInstance(); From a982cc7e1af9a084ab377d6fea6bb9151fdebaad Mon Sep 17 00:00:00 2001 From: bhanu-dev82 Date: Mon, 13 Jan 2025 18:36:23 +0530 Subject: [PATCH 08/16] fix the error in chat list screen --- android/app/src/main/AndroidManifest.xml | 3 +- lib/pages/device_list_screen.dart | 45 +++++++++++------------- 2 files changed, 23 insertions(+), 25 deletions(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 932a54b..ef5aee0 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,5 @@ - + diff --git a/lib/pages/device_list_screen.dart b/lib/pages/device_list_screen.dart index a1a5f92..7ab1462 100644 --- a/lib/pages/device_list_screen.dart +++ b/lib/pages/device_list_screen.dart @@ -26,22 +26,32 @@ class DevicesListScreen extends StatefulWidget { class _DevicesListScreenState extends State { bool isInit = false; - bool isLoading = false; - TextEditingController searchController = TextEditingController(); List filteredDevices = []; + void refreshDeviceList() { + setState(() { + if (searchController.text.isEmpty) { + filteredDevices = Provider.of(context, listen: false).devices; + } else { + _filterDevices(); + } + }); + } @override void initState() { super.initState(); searchController.addListener(_filterDevices); + Provider.of(context, listen: false).addListener(refreshDeviceList); + filteredDevices = Provider.of(context, listen: false).devices; } @override void dispose() { searchController.removeListener(_filterDevices); + Provider.of(context, listen: false).removeListener(refreshDeviceList); searchController.dispose(); super.dispose(); } @@ -59,12 +69,8 @@ class _DevicesListScreenState extends State { }); } - @override Widget build(BuildContext context) { - if (filteredDevices.isEmpty && searchController.text.isEmpty) { - filteredDevices = Provider.of(context).devices; - } return SingleChildScrollView( child: Column( children: [ @@ -90,12 +96,11 @@ class _DevicesListScreenState extends State { ), ), ListView.builder( - // Builds a screen with list of devices in the proximity - itemCount: filteredDevices.length, + itemCount: filteredDevices.length, shrinkWrap: true, itemBuilder: (context, index) { - // Getting a device from the provider - final device = Provider.of(context).devices[index]; + // Get device from filteredDevices + final device = filteredDevices[index]; return Container( margin: const EdgeInsets.all(8.0), child: Column( @@ -103,8 +108,6 @@ class _DevicesListScreenState extends State { ListTile( title: Text(device.deviceName), trailing: GestureDetector( - // GestureDetector act as onPressed() and enables - // to connect/disconnect with any device onTap: () => connectToDevice(device), child: Container( decoration: BoxDecoration( @@ -120,29 +123,23 @@ class _DevicesListScreenState extends State { getButtonStateName(device.state), style: const TextStyle( color: Colors.white, - fontWeight: FontWeight.bold), + fontWeight: FontWeight.bold + ), ), ), ), ), onTap: () { - // On clicking any device tile, we navigate to the - // ChatPage. Navigator.of(context).push( MaterialPageRoute( - builder: (context) { - return ChatPage( - converser: device.deviceName, - ); - }, + builder: (context) => ChatPage( + converser: device.deviceName, + ), ), ); }, ), - const Divider( - height: 1, - color: Colors.grey, - ), + const Divider(height: 1, color: Colors.grey), ], ), ); From 493aca2408c99437526879f6da5d7cecbd166bfb Mon Sep 17 00:00:00 2001 From: bhanu-dev82 Date: Mon, 13 Jan 2025 18:40:15 +0530 Subject: [PATCH 09/16] added back the comments --- lib/pages/device_list_screen.dart | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lib/pages/device_list_screen.dart b/lib/pages/device_list_screen.dart index 7ab1462..a47a1db 100644 --- a/lib/pages/device_list_screen.dart +++ b/lib/pages/device_list_screen.dart @@ -96,6 +96,7 @@ class _DevicesListScreenState extends State { ), ), ListView.builder( + // Builds a screen with list of devices in the proximity itemCount: filteredDevices.length, shrinkWrap: true, itemBuilder: (context, index) { @@ -108,6 +109,8 @@ class _DevicesListScreenState extends State { ListTile( title: Text(device.deviceName), trailing: GestureDetector( + // GestureDetector act as onPressed() and enables + // to connect/disconnect with any device onTap: () => connectToDevice(device), child: Container( decoration: BoxDecoration( @@ -130,6 +133,8 @@ class _DevicesListScreenState extends State { ), ), onTap: () { + // On clicking any device tile, we navigate to the + // ChatPage. Navigator.of(context).push( MaterialPageRoute( builder: (context) => ChatPage( From 8f4c58a422b792d1a8116e3dcf6567b4c048b964 Mon Sep 17 00:00:00 2001 From: khushi-hura Date: Mon, 13 Jan 2025 22:22:35 +0530 Subject: [PATCH 10/16] new changes --- android/app/build.gradle | 2 +- android/app/src/main/AndroidManifest.xml | 36 +++++++++++++++--------- 2 files changed, 23 insertions(+), 15 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 9daee03..57aaebe 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 34 + compileSdk = flutter.compileSdkVersion ndkVersion = flutter.ndkVersion compileOptions { diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index f447eed..ef5aee0 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,21 +1,23 @@ - + - - - - - - - - - - + + + + + + + + + + + + + + - - - + + Date: Tue, 14 Jan 2025 01:27:54 +0530 Subject: [PATCH 11/16] fix permission error on android 13+ --- android/app/src/main/AndroidManifest.xml | 9 +- lib/components/audio_service.dart | 115 ++++-------------- lib/main.dart | 59 ++++++--- macos/Flutter/GeneratedPluginRegistrant.swift | 2 + pubspec.yaml | 1 + 5 files changed, 75 insertions(+), 111 deletions(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index ef5aee0..ee8455e 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -18,10 +18,17 @@ + + + + + + + android:icon="@mipmap/ic_launcher" + android:enableOnBackInvokedCallback="true"> 'RecordingPermissionException: $message'; } -class PermissionService { - static Future handleMicrophonePermission(BuildContext context) async { - if (Platform.isIOS) { - final status = await Permission.microphone.status; - debugPrint('iOS Microphone Permission Status: $status'); - - if (status.isDenied) { - final result = await Permission.microphone.request(); - debugPrint('iOS Permission Request Result: $result'); - return result.isGranted; - } - - if (status.isPermanentlyDenied) { - if (context.mounted) { - await _showiOSSettingsDialog(context); - } - return false; - } - - return status.isGranted; - } - - return true; - } - - static Future _showiOSSettingsDialog(BuildContext context) async { - return showDialog( - context: context, - barrierDismissible: false, - builder: (BuildContext context) { - return CupertinoAlertDialog( - title: const Text('Microphone Access Required'), - content: const Text( - 'OpenPeerChat needs access to your microphone to record voice messages.\n\n' - 'Please enable microphone access in Settings.' - ), - actions: [ - CupertinoDialogAction( - child: const Text('Cancel'), - onPressed: () => Navigator.of(context).pop(), - ), - CupertinoDialogAction( - isDefaultAction: true, - child: const Text('Settings'), - onPressed: () { - Navigator.of(context).pop(); - openAppSettings(); - }, - ), - ], - ); - }, - ); - } -} - class AudioService { static final AudioService _instance = AudioService._internal(); factory AudioService() => _instance; @@ -82,29 +27,24 @@ class AudioService { bool _isRecording = false; String? _currentRecordingPath; - // Getters bool get isRecording => _isRecording; String? get currentRecordingPath => _currentRecordingPath; Future _requestPermissions() async { try { - final permissions = [Permission.microphone]; if (Platform.isAndroid) { - permissions.add(Permission.storage); - } - - debugPrint('Requesting permissions: $permissions'); - final statuses = await permissions.request(); - - bool allGranted = true; - statuses.forEach((permission, status) { - debugPrint('Permission $permission status: $status'); - if (status != PermissionStatus.granted) { - allGranted = false; + final androidInfo = await DeviceInfoPlugin().androidInfo; + if (androidInfo.version.sdkInt >= 33) { + await Permission.audio.request(); + await Permission.videos.request(); + } else { + await Permission.storage.request(); + await Permission.manageExternalStorage.request(); } - }); + } - return allGranted; + final micStatus = await Permission.microphone.request(); + return micStatus.isGranted; } catch (e) { debugPrint('Permission request error: $e'); return false; @@ -120,22 +60,17 @@ class AudioService { if (!permissionsGranted) { throw RecordingPermissionException( - 'Required permissions were not granted. Please enable microphone access in your device settings.' - ); - } - - if (!await _recorder.hasPermission()) { - throw RecordingPermissionException( - 'Microphone permission not available. Please check your device settings.' + 'Required permissions were not granted. Please enable necessary permissions in your device settings.' ); } + await _recorder.hasPermission(); _isRecorderInitialized = true; debugPrint('Recorder initialized successfully'); } catch (e) { _isRecorderInitialized = false; debugPrint('Recorder initialization failed: $e'); - throw RecordingPermissionException(e.toString()); + rethrow; } } @@ -160,13 +95,11 @@ class AudioService { ); _isRecording = true; - debugPrint('Started recording at: $_currentRecordingPath'); return _currentRecordingPath!; } catch (e) { _isRecording = false; _currentRecordingPath = null; - debugPrint('Recording failed: $e'); - throw RecordingPermissionException('Failed to start recording: $e'); + rethrow; } } @@ -178,11 +111,9 @@ class AudioService { _isRecording = false; final recordedPath = _currentRecordingPath; _currentRecordingPath = null; - debugPrint('Stopped recording. File saved at: $recordedPath'); return recordedPath; } catch (e) { - debugPrint('Failed to stop recording: $e'); - throw RecordingPermissionException('Failed to stop recording: $e'); + rethrow; } } @@ -191,8 +122,7 @@ class AudioService { await _player.setFilePath(path); await _player.play(); } catch (e) { - debugPrint('Playback failed: $e'); - throw RecordingPermissionException('Failed to play recording: $e'); + rethrow; } } @@ -200,8 +130,7 @@ class AudioService { try { await _player.stop(); } catch (e) { - debugPrint('Failed to stop playback: $e'); - throw RecordingPermissionException('Failed to stop playback: $e'); + rethrow; } } @@ -213,10 +142,8 @@ class AudioService { await _recorder.dispose(); await _player.dispose(); _isRecorderInitialized = false; - debugPrint('Audio service disposed successfully'); } catch (e) { - debugPrint('Failed to dispose audio service: $e'); - throw RecordingPermissionException('Failed to dispose recorder: $e'); + rethrow; } } -} \ No newline at end of file +} diff --git a/lib/main.dart b/lib/main.dart index bf66331..51468de 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -4,27 +4,56 @@ import 'package:flutter_nearby_connections_example/pages/auth_fail.dart'; import 'package:flutter_nearby_connections_example/pages/profile.dart'; import 'package:local_auth/local_auth.dart'; import 'package:provider/provider.dart'; +import 'package:permission_handler/permission_handler.dart'; import 'classes/global.dart'; import 'encyption/key_storage.dart'; import 'encyption/rsa.dart'; import 'classes/themeProvider.dart'; +Future requestPermissions() async { + final permissions = [ + Permission.storage, + Permission.microphone, + Permission.manageExternalStorage, + Permission.nearbyWifiDevices, + Permission.location, + Permission.bluetooth, + Permission.bluetoothScan, + Permission.bluetoothAdvertise, + Permission.bluetoothConnect + ]; + + for (var permission in permissions) { + if (await permission.status.isDenied) { + final status = await permission.request(); + if (status.isPermanentlyDenied) { + openAppSettings(); + break; + } + } + } +} + + + + + void main() async { WidgetsFlutterBinding.ensureInitialized(); + // Request permissions first + await requestPermissions(); + final keyStorage = KeyStorage(); - // Check if keys already exist String? privateKeyPem = await keyStorage.getPrivateKey(); String? publicKeyPem = await keyStorage.getPublicKey(); if (privateKeyPem == null || publicKeyPem == null) { - // Generate RSA key pair final pair = generateRSAkeyPair(exampleSecureRandom()); privateKeyPem = encodePrivateKeyToPem(pair.privateKey); publicKeyPem = encodePublicKeyToPem(pair.publicKey); - // Store keys await keyStorage.savePrivateKey(privateKeyPem); await keyStorage.savePublicKey(publicKeyPem); } @@ -50,23 +79,22 @@ class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { - final themeProvider = Provider.of(context); + final themeProvider = Provider.of(context); return Consumer( - builder: (context, themeProvider, child) { - return MaterialApp( - theme: lightTheme, - darkTheme: darkTheme, - themeMode: themeProvider.themeMode, - debugShowCheckedModeBanner: false, - onGenerateRoute: generateRoute, - initialRoute: '/', - ); - } + builder: (context, themeProvider, child) { + return MaterialApp( + theme: lightTheme, + darkTheme: darkTheme, + themeMode: themeProvider.themeMode, + debugShowCheckedModeBanner: false, + onGenerateRoute: generateRoute, + initialRoute: '/', + ); + } ); } } - Future _authenticate(BuildContext context) async { final LocalAuthentication auth = LocalAuthentication(); bool authenticated = false; @@ -110,4 +138,3 @@ Route generateRoute(RouteSettings settings) { }, ); } - diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index ee7c3f7..1515165 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -7,6 +7,7 @@ import Foundation import audio_session import audioplayers_darwin +import device_info_plus import flutter_secure_storage_macos import just_audio import local_auth_darwin @@ -18,6 +19,7 @@ import sqflite_darwin func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { AudioSessionPlugin.register(with: registry.registrar(forPlugin: "AudioSessionPlugin")) AudioplayersDarwinPlugin.register(with: registry.registrar(forPlugin: "AudioplayersDarwinPlugin")) + DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) JustAudioPlugin.register(with: registry.registrar(forPlugin: "JustAudioPlugin")) FLALocalAuthPlugin.register(with: registry.registrar(forPlugin: "FLALocalAuthPlugin")) diff --git a/pubspec.yaml b/pubspec.yaml index a69ac06..70cb06c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -35,6 +35,7 @@ dependencies: just_audio: ^0.9.42 record: ^4.4.4 audioplayers: ^6.1.0 + device_info_plus: ^11.2.0 dev_dependencies: From 63818219de0366556c269b6cbf785d0077584d28 Mon Sep 17 00:00:00 2001 From: bhanu-dev82 Date: Tue, 14 Jan 2025 01:41:12 +0530 Subject: [PATCH 12/16] enhanced permission according to android version --- lib/main.dart | 73 ++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 55 insertions(+), 18 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index 51468de..9907c7f 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -10,27 +10,63 @@ import 'encyption/key_storage.dart'; import 'encyption/rsa.dart'; import 'classes/themeProvider.dart'; +import 'dart:io'; +import 'package:device_info_plus/device_info_plus.dart'; + Future requestPermissions() async { - final permissions = [ - Permission.storage, - Permission.microphone, - Permission.manageExternalStorage, - Permission.nearbyWifiDevices, - Permission.location, - Permission.bluetooth, - Permission.bluetoothScan, - Permission.bluetoothAdvertise, - Permission.bluetoothConnect - ]; - - for (var permission in permissions) { - if (await permission.status.isDenied) { - final status = await permission.request(); - if (status.isPermanentlyDenied) { - openAppSettings(); - break; + try { + if (Platform.isAndroid) { + final androidInfo = await DeviceInfoPlugin().androidInfo; + final sdkInt = androidInfo.version.sdkInt; + + // Base permissions for all Android versions + final basePermissions = [ + Permission.storage, + Permission.microphone, + Permission.location, + Permission.bluetooth, + ]; + + // Permissions for Android 12 and above + final modernPermissions = [ + Permission.bluetoothScan, + Permission.bluetoothAdvertise, + Permission.bluetoothConnect, + Permission.nearbyWifiDevices, + ]; + + // Permissions for Android 10 and above + final storagePermissions = [ + Permission.manageExternalStorage, + ]; + + final permissions = [...basePermissions]; + + if (sdkInt >= 31) { // Android 12 or higher + permissions.addAll(modernPermissions); } + + if (sdkInt >= 29) { // Android 10 or higher + permissions.addAll(storagePermissions); + } + + for (var permission in permissions) { + if (await permission.status.isDenied) { + final status = await permission.request(); + if (status.isPermanentlyDenied) { + openAppSettings(); + break; + } + } + } + } else { + // iOS permissions + await Permission.microphone.request(); + await Permission.bluetooth.request(); + await Permission.location.request(); } + } catch (e) { + debugPrint('Permission request error: $e'); } } @@ -38,6 +74,7 @@ Future requestPermissions() async { + void main() async { WidgetsFlutterBinding.ensureInitialized(); From 09fba7e0809482a231df2c6beb018d1035ca862b Mon Sep 17 00:00:00 2001 From: Bhavik Mangla Date: Tue, 14 Jan 2025 18:08:04 +0530 Subject: [PATCH 13/16] Fixed bugs in device_list_screen and theme Provider Code, also added support to save the theme chosen and added multiple themes option, improved Ui for profile Page. Fixed bugs for sending voice and improved voice bubble UI. --- lib/classes/themeProvider.dart | 12 - lib/components/message_panel.dart | 1 + lib/main.dart | 9 +- lib/pages/chat_page.dart | 1 - lib/pages/device_list_screen.dart | 59 +++-- lib/pages/profile.dart | 360 +++++++++++++++++------------- lib/providers/theme_provider.dart | 76 +++++++ 7 files changed, 315 insertions(+), 203 deletions(-) delete mode 100644 lib/classes/themeProvider.dart create mode 100644 lib/providers/theme_provider.dart diff --git a/lib/classes/themeProvider.dart b/lib/classes/themeProvider.dart deleted file mode 100644 index 6004f51..0000000 --- a/lib/classes/themeProvider.dart +++ /dev/null @@ -1,12 +0,0 @@ -import 'package:flutter/material.dart'; - -class ThemeProvider extends ChangeNotifier { - ThemeMode themeMode = ThemeMode.light; - - ThemeMode getThemeMode() => themeMode; - - void toggleTheme(bool isDarkMode) { - themeMode = isDarkMode ? ThemeMode.dark : ThemeMode.light; - notifyListeners(); - } -} \ No newline at end of file diff --git a/lib/components/message_panel.dart b/lib/components/message_panel.dart index b6d5e49..6434216 100644 --- a/lib/components/message_panel.dart +++ b/lib/components/message_panel.dart @@ -46,6 +46,7 @@ class _MessagePanelState extends State { await _audioService.initRecorder(); } catch (e) { // Show error dialog or snackbar to user + if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(e.toString()), diff --git a/lib/main.dart b/lib/main.dart index 9907c7f..72047bd 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -8,7 +8,7 @@ import 'package:permission_handler/permission_handler.dart'; import 'classes/global.dart'; import 'encyption/key_storage.dart'; import 'encyption/rsa.dart'; -import 'classes/themeProvider.dart'; +import 'providers/theme_provider.dart'; import 'dart:io'; import 'package:device_info_plus/device_info_plus.dart'; @@ -116,13 +116,10 @@ class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { - final themeProvider = Provider.of(context); return Consumer( - builder: (context, themeProvider, child) { + builder: (context, themeProvider, _) { return MaterialApp( - theme: lightTheme, - darkTheme: darkTheme, - themeMode: themeProvider.themeMode, + theme: themeProvider.theme, debugShowCheckedModeBanner: false, onGenerateRoute: generateRoute, initialRoute: '/', diff --git a/lib/pages/chat_page.dart b/lib/pages/chat_page.dart index 1d0b7e6..f3a303e 100644 --- a/lib/pages/chat_page.dart +++ b/lib/pages/chat_page.dart @@ -159,7 +159,6 @@ class ChatPageState extends State { ); } Widget _buildVoiceMessageBubble(Msg msg) { - final data = jsonDecode(msg.message); final bool isCurrentlyPlaying = _currentlyPlayingId == msg.id; return Container( diff --git a/lib/pages/device_list_screen.dart b/lib/pages/device_list_screen.dart index a47a1db..0d8af39 100644 --- a/lib/pages/device_list_screen.dart +++ b/lib/pages/device_list_screen.dart @@ -29,44 +29,55 @@ class _DevicesListScreenState extends State { bool isLoading = false; TextEditingController searchController = TextEditingController(); List filteredDevices = []; + Global? globalProvider; - void refreshDeviceList() { - setState(() { - if (searchController.text.isEmpty) { - filteredDevices = Provider.of(context, listen: false).devices; - } else { - _filterDevices(); - } - }); + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (!isInit) { + globalProvider = Provider.of(context, listen: false); + globalProvider?.addListener(_handleGlobalUpdate); + _updateFilteredDevices(); + isInit = true; + } } - @override - void initState() { - super.initState(); - searchController.addListener(_filterDevices); - Provider.of(context, listen: false).addListener(refreshDeviceList); - filteredDevices = Provider.of(context, listen: false).devices; + void _handleGlobalUpdate() { + if (mounted) { + _updateFilteredDevices(); + } + } + + void _updateFilteredDevices() { + if (mounted) { + setState(() { + if (searchController.text.isEmpty) { + filteredDevices = globalProvider?.devices ?? []; + } else { + _filterDevices(); + } + }); + } } @override void dispose() { searchController.removeListener(_filterDevices); - Provider.of(context, listen: false).removeListener(refreshDeviceList); + globalProvider?.removeListener(_handleGlobalUpdate); searchController.dispose(); super.dispose(); } void _filterDevices() { - setState(() { - if (searchController.text.isEmpty) { - filteredDevices = Provider.of(context, listen: false).devices; - } else { - filteredDevices = Provider.of(context, listen: false) - .devices - .where((device) => device.deviceName.toLowerCase().contains(searchController.text.toLowerCase())) + if (mounted && globalProvider != null) { + setState(() { + filteredDevices = globalProvider!.devices + .where((device) => device.deviceName + .toLowerCase() + .contains(searchController.text.toLowerCase())) .toList(); - } - }); + }); + } } @override diff --git a/lib/pages/profile.dart b/lib/pages/profile.dart index 417c371..77bea6e 100644 --- a/lib/pages/profile.dart +++ b/lib/pages/profile.dart @@ -1,48 +1,11 @@ import 'dart:async'; import 'package:flutter/material.dart'; -import 'package:flutter_nearby_connections_example/classes/themeProvider.dart'; import 'package:provider/provider.dart'; -import 'home_screen.dart'; -import 'package:nanoid/nanoid.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:nanoid/nanoid.dart'; import '../classes/global.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, - ), - ), -); +import '../providers/theme_provider.dart'; +import 'home_screen.dart'; class Profile extends StatefulWidget { final bool onLogin; @@ -53,163 +16,240 @@ class Profile extends StatefulWidget { } class _ProfileState extends State { - - //initial theme of the system - ThemeMode _themeMode = ThemeMode.light; - - // TextEditingController for the name of the user TextEditingController myName = TextEditingController(); - - // loading variable is used for UI purpose when the app is fetching - // user details bool loading = true; - - // Custom generated id for the user var customLengthId = nanoid(6); - // Fetching details from saved profile - // If no profile is saved, then the new values are used - // else navigate to DeviceListScreen - Future getDetails() async { - // Obtain shared preferences. - final prefs = await SharedPreferences.getInstance(); - final name = prefs.getString('p_name') ?? ''; - final id = prefs.getString('p_id') ?? ''; - setState(() { - myName.text = name; - customLengthId = id.isNotEmpty ? id : customLengthId; - }); - if (name.isNotEmpty && id.isNotEmpty && widget.onLogin) { - navigateToHomeScreen(); - } else { - setState(() { - loading = false; - }); + @override + void initState() { + super.initState(); + getDetails(); + } + + Future getDetails() async { + try { + final prefs = await SharedPreferences.getInstance(); + final name = prefs.getString('p_name') ?? ''; + final id = prefs.getString('p_id') ?? ''; + + if (mounted) { + setState(() { + myName.text = name; + customLengthId = id.isNotEmpty ? id : customLengthId; + }); + + if (name.isNotEmpty && id.isNotEmpty && widget.onLogin) { + navigateToHomeScreen(); + } else { + setState(() { + loading = false; + }); + } + } + } catch (e) { + if (mounted) { + setState(() { + loading = false; + }); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error loading profile: $e')), + ); + } } } - // It is a general function to navigate to home screen. - // If we are first launching the app, we need to replace the profile page - // from the context and then open the home screen - // Otherwise we need to pop out the profile screen context - // from memory of the application. This is a flutter way - // to manage different contexts and screens. void navigateToHomeScreen() { Global.myName = myName.text; if (!widget.onLogin) { - Global.myName = myName.text; Navigator.pop(context); } else { Navigator.pushReplacement( context, - MaterialPageRoute( - builder: (context) => const HomeScreen(), - ), + MaterialPageRoute(builder: (context) => const HomeScreen()), ); } } - @override - void initState() { - super.initState(); - _loadTheme(); - // At the launch we are fetching details using the getDetails function - getDetails(); - } - - Future _loadTheme() async { - SharedPreferences prefs = await SharedPreferences.getInstance(); - int? themeIndex = prefs.getInt(themePreferenceKey); - if (themeIndex != null) { - setState(() { - _themeMode = ThemeMode.values[themeIndex]; - }); + Future saveProfile() async { + if (myName.text.trim().isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Please enter a valid name')), + ); + return; } - } - Future _saveTheme(ThemeMode mode) async { - SharedPreferences prefs = await SharedPreferences.getInstance(); - await prefs.setInt(themePreferenceKey, mode.index); + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('p_name', myName.text.trim()); + await prefs.setString('p_id', customLengthId); + navigateToHomeScreen(); + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error saving profile: $e')), + ); + } } - void _toggleTheme(bool value) { - setState(() { - _themeMode = value ? ThemeMode.dark : ThemeMode.light; - }); - _saveTheme(_themeMode); + Widget buildThemeSelector() { + return Consumer( + builder: (context, themeProvider, _) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Text( + 'Theme Selection', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + Container( + height: 120, + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: ThemeProvider.availableThemes.length, + itemBuilder: (context, index) { + String themeName = ThemeProvider.availableThemes.keys.elementAt(index); + bool isSelected = themeName == themeProvider.currentTheme; + + return Padding( + padding: const EdgeInsets.all(8.0), + child: Material( + elevation: isSelected ? 8 : 2, + borderRadius: BorderRadius.circular(12), + child: InkWell( + onTap: () => themeProvider.setTheme(themeName), + borderRadius: BorderRadius.circular(12), + child: Container( + width: 100, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: isSelected + ? Border.all( + color: Theme.of(context).colorScheme.primary, + width: 2, + ) + : null, + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + isSelected + ? Icons.check_circle + : Icons.brightness_auto, + color: ThemeProvider.availableThemes[themeName]!.primaryColor, + size: 32, + ), + const SizedBox(height: 8), + Text( + themeName, + style: TextStyle( + fontWeight: isSelected + ? FontWeight.bold + : FontWeight.normal, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ), + ), + ); + }, + ), + ), + ], + ); + }, + ); } @override Widget build(BuildContext context) { - final themeProvider = Provider.of(context); return Scaffold( appBar: AppBar( - title: const Text( - 'Profile', - ), + title: const Text('Profile'), + elevation: 0, ), - body: Visibility( - visible: loading, - replacement: Column( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - - Padding( - padding: const EdgeInsets.all(10.0), - child: TextFormField( - controller: myName, - decoration: const InputDecoration( - icon: Icon(Icons.person), - hintText: 'What do people call you?', - labelText: 'Name *', - border: OutlineInputBorder(), - ), - validator: (String? value) { - return (value != null && - value.contains('@') && - value.length > 3) - ? 'Do not use the @ char and name length should be greater than 3' - : null; - }, - ), - ), - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, + body: loading + ? const Center(child: CircularProgressIndicator()) + : SingleChildScrollView( + child: SafeArea( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Text( - 'Switch to dark theme', - style: const TextStyle( - fontSize: 20, + Card( + elevation: 4, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(15), + ), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Your Profile', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 16), + TextFormField( + controller: myName, + decoration: InputDecoration( + labelText: 'Name', + hintText: 'What do people call you?', + prefixIcon: const Icon(Icons.person), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + filled: true, + ), + ), + const SizedBox(height: 12), + Text( + 'Your ID: $customLengthId', + style: Theme.of(context).textTheme.bodyLarge, + ), + ], + ), ), ), - Switch( - value: themeProvider.themeMode == ThemeMode.dark, - onChanged: (value) { - themeProvider.toggleTheme(value); - }, - activeColor: Colors.blueAccent, - inactiveThumbColor: Colors.grey, + const SizedBox(height: 24), + buildThemeSelector(), + const SizedBox(height: 24), + ElevatedButton( + onPressed: saveProfile, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: const Text( + 'Save Profile', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), ), ], ), - ElevatedButton( - onPressed: () async { - final prefs = await SharedPreferences.getInstance(); - // saving the name and id to shared preferences - prefs.setString('p_name', myName.text); - prefs.setString('p_id', customLengthId); - - // On pressing, move to the home screen - navigateToHomeScreen(); - }, - child: const Text("Save"), - ) - ], - ), - child: const Center( - child: CircularProgressIndicator(), + ), ), ), ); } + + @override + void dispose() { + myName.dispose(); + super.dispose(); + } } \ No newline at end of file diff --git a/lib/providers/theme_provider.dart b/lib/providers/theme_provider.dart new file mode 100644 index 0000000..25b557b --- /dev/null +++ b/lib/providers/theme_provider.dart @@ -0,0 +1,76 @@ + +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class ThemeProvider with ChangeNotifier { + static const String _themePreferenceKey = 'selected_theme'; + + // Define available themes + static final Map availableThemes = { + 'Light': ThemeData( + brightness: Brightness.light, + primaryColor: Colors.blue, + colorScheme: ColorScheme.light( + primary: Colors.blue, + secondary: Colors.blueAccent, + ), + ), + 'Dark': ThemeData( + brightness: Brightness.dark, + primaryColor: Colors.blueGrey[900], + colorScheme: ColorScheme.dark( + primary: Colors.blueGrey[900]!, + secondary: Colors.blueAccent, + ), + ), + 'Nature': ThemeData( + brightness: Brightness.light, + primaryColor: Colors.green, + colorScheme: ColorScheme.light( + primary: Colors.green, + secondary: Colors.lightGreen, + ), + ), + 'Ocean': ThemeData( + brightness: Brightness.light, + primaryColor: Colors.cyan, + colorScheme: ColorScheme.light( + primary: Colors.cyan, + secondary: Colors.lightBlue, + ), + ), + 'Sunset': ThemeData( + brightness: Brightness.light, + primaryColor: Colors.orange, + colorScheme: ColorScheme.light( + primary: Colors.orange, + secondary: Colors.deepOrange, + ), + ), + }; + + String _currentTheme = 'Light'; + + ThemeProvider() { + loadTheme(); + } + + String get currentTheme => _currentTheme; + ThemeData get theme => availableThemes[_currentTheme]!; + + Future loadTheme() async { + final prefs = await SharedPreferences.getInstance(); + _currentTheme = prefs.getString(_themePreferenceKey) ?? 'Light'; + notifyListeners(); + } + + Future setTheme(String themeName) async { + if (!availableThemes.containsKey(themeName)) return; + + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_themePreferenceKey, themeName); + + _currentTheme = themeName; + notifyListeners(); + } +} \ No newline at end of file From 64a8eaabc652e9ee301e50b67532a5b0780c169d Mon Sep 17 00:00:00 2001 From: Bhavik Mangla Date: Tue, 14 Jan 2025 18:08:22 +0530 Subject: [PATCH 14/16] Fixed bugs in device_list_screen and theme Provider Code, also added support to save the theme chosen and added multiple themes option, improved Ui for profile Page. Fixed bugs for sending voice and improved voice bubble UI. --- lib/classes/global.dart | 4 +- lib/components/audio_service.dart | 32 ++- lib/components/message_panel.dart | 4 +- lib/p2p/adhoc_housekeeping.dart | 2 +- lib/pages/chat_page.dart | 325 ++++++++++++++++++++---------- 5 files changed, 244 insertions(+), 123 deletions(-) diff --git a/lib/classes/global.dart b/lib/classes/global.dart index d7cd71d..332078a 100644 --- a/lib/classes/global.dart +++ b/lib/classes/global.dart @@ -58,8 +58,10 @@ class Global extends ChangeNotifier { ); conversations.putIfAbsent(sender, () => {}); if (!conversations[sender]!.containsKey(decodedMessage['id'])) { + print("Adding to conversations"); + print("Message: ${message['type']}"); decodedMessage['message'] = json.encode({ - 'type': 'file', + 'type': message['type'], 'filePath': filePath, 'fileName': message['fileName'] }); diff --git a/lib/components/audio_service.dart b/lib/components/audio_service.dart index 88eae49..721b446 100644 --- a/lib/components/audio_service.dart +++ b/lib/components/audio_service.dart @@ -35,22 +35,40 @@ class AudioService { if (Platform.isAndroid) { final androidInfo = await DeviceInfoPlugin().androidInfo; if (androidInfo.version.sdkInt >= 33) { - await Permission.audio.request(); - await Permission.videos.request(); + // Check status before requesting + if (await Permission.audio.status.isDenied) { + await Permission.audio.request(); + } + if (await Permission.videos.status.isDenied) { + await Permission.videos.request(); + } } else { - await Permission.storage.request(); - await Permission.manageExternalStorage.request(); + // For older Android versions + if (await Permission.storage.status.isDenied) { + await Permission.storage.request(); + } + if (await Permission.manageExternalStorage.status.isDenied) { + await Permission.manageExternalStorage.request(); + } } } - final micStatus = await Permission.microphone.request(); - return micStatus.isGranted; + // Check microphone permission + if (await Permission.microphone.status.isDenied) { + final micStatus = await Permission.microphone.request(); + if (micStatus.isPermanentlyDenied) { + // Provide user feedback + debugPrint('Microphone permission permanently denied'); + return false; + } + return micStatus.isGranted; + } + return true; } catch (e) { debugPrint('Permission request error: $e'); return false; } } - Future initRecorder() async { if (_isRecorderInitialized) return; diff --git a/lib/components/message_panel.dart b/lib/components/message_panel.dart index 6434216..c9aef41 100644 --- a/lib/components/message_panel.dart +++ b/lib/components/message_panel.dart @@ -20,8 +20,8 @@ import 'view_file.dart'; /// connected devices. class MessagePanel extends StatefulWidget { - const MessagePanel({Key? key, required this.converser}) : super(key: key); - final String converser; + const MessagePanel({Key? key, required this.converser, this.onMessageSent}) : super(key: key); + final String converser;final VoidCallback? onMessageSent; @override State createState() => _MessagePanelState(); diff --git a/lib/p2p/adhoc_housekeeping.dart b/lib/p2p/adhoc_housekeeping.dart index 87e9dbb..1add305 100644 --- a/lib/p2p/adhoc_housekeeping.dart +++ b/lib/p2p/adhoc_housekeeping.dart @@ -160,7 +160,7 @@ void broadcast(BuildContext context) async { finalData = { - "type": "file", + "type": message['type'], "data": encodedMessage, "fileName": message['fileName'], }; diff --git a/lib/pages/chat_page.dart b/lib/pages/chat_page.dart index f3a303e..e3cce21 100644 --- a/lib/pages/chat_page.dart +++ b/lib/pages/chat_page.dart @@ -28,26 +28,54 @@ class ChatPageState extends State { final AudioPlayer _audioPlayer = AudioPlayer(); String? _currentlyPlayingId; bool _isPlaying = false; + final ScrollController _scrollController = ScrollController(); + bool _isFirstBuild = true; // Add this flag - + String _formatDuration(Duration duration) { + String twoDigits(int n) => n.toString().padLeft(2, '0'); + String twoDigitMinutes = twoDigits(duration.inMinutes.remainder(60)); + String twoDigitSeconds = twoDigits(duration.inSeconds.remainder(60)); + return "$twoDigitMinutes:$twoDigitSeconds"; + } @override void initState() { super.initState(); + _audioPlayer.setReleaseMode(ReleaseMode.stop); // Stop when completed + _audioPlayer.onPlayerComplete.listen((event) { + if (mounted) { + setState(() { + _currentlyPlayingId = null; + _isPlaying = false; + }); + } + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + _scrollToBottom(); + }); } @override void dispose() { + _scrollController.dispose(); _audioPlayer.dispose(); super.dispose(); } + void _scrollToBottom() { + if (_scrollController.hasClients) { + _scrollController.animateTo( + _scrollController.position.maxScrollExtent, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + } + } + @override void didChangeDependencies() { super.didChangeDependencies(); } - final ScrollController _scrollController = ScrollController(); - @override Widget build(BuildContext context) { @@ -58,13 +86,11 @@ class ChatPageState extends State { .forEach((key, value) { messageList.add(value); }); - - if (_scrollController.hasClients) { - _scrollController.animateTo( - _scrollController.position.maxScrollExtent + 50, - duration: const Duration(milliseconds: 300), - curve: Curves.easeOut, - ); + if (_isFirstBuild) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _scrollToBottom(); + _isFirstBuild = false; + }); } } @@ -89,106 +115,183 @@ class ChatPageState extends State { 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), + 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); + } + } + 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), + ), + ), + ], + ); + }), + ], + ); + }, ), - ), - ), - ...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), - ), - ), - ), - 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, + onMessageSent: () { + // Scroll to bottom when new message is sent + WidgetsBinding.instance.addPostFrameCallback((_) { + _scrollToBottom(); + }); + }, + ), ], ), ); } Widget _buildVoiceMessageBubble(Msg msg) { + final data = jsonDecode(msg.message); final bool isCurrentlyPlaying = _currentlyPlayingId == msg.id; return Container( - constraints: const BoxConstraints(maxWidth: 250), + constraints: const BoxConstraints(maxWidth: 280), child: Row( mainAxisSize: MainAxisSize.min, children: [ - IconButton( - icon: Icon( - isCurrentlyPlaying && _isPlaying ? Icons.pause : Icons.play_arrow, - color: Colors.black87, + // Play/Pause Button + Container( + height: 40, + width: 40, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: msg.msgtype == 'sent' + ? Colors.deepPurple.withOpacity(0.2) + : Colors.cyan.withOpacity(0.2), + ), + child: IconButton( + padding: EdgeInsets.zero, + icon: Icon( + isCurrentlyPlaying && _isPlaying + ? Icons.pause_rounded + : Icons.play_arrow_rounded, + color: msg.msgtype == 'sent' + ? Colors.deepPurple + : Colors.cyan[700], + size: 24, + ), + onPressed: () => _handleVoicePlayback(msg), ), - onPressed: () => _handleVoicePlayback(msg), ), + const SizedBox(width: 8), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, children: [ - const Text( - 'Voice Message', - style: TextStyle( - color: Colors.black87, - fontWeight: FontWeight.w500, + // Waveform/Progress Bar + Container( + height: 28, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), ), - ), - if (isCurrentlyPlaying) - const LinearProgressIndicator( - backgroundColor: Colors.grey, - valueColor: AlwaysStoppedAnimation(Colors.blue), + child: ClipRRect( + borderRadius: BorderRadius.circular(20), + child: StreamBuilder( + stream: _audioPlayer.onPositionChanged, + builder: (context, snapshot) { + return FutureBuilder( + future: _audioPlayer.getDuration(), + builder: (context, durationSnapshot) { + final totalDuration = durationSnapshot.data?.inMilliseconds ?? 1; + return LinearProgressIndicator( + value: isCurrentlyPlaying && snapshot.hasData + ? snapshot.data!.inMilliseconds / totalDuration + : 0, + backgroundColor: msg.msgtype == 'sent' + ? Colors.deepPurple.withOpacity(0.1) + : Colors.cyan.withOpacity(0.1), + valueColor: AlwaysStoppedAnimation( + msg.msgtype == 'sent' + ? Colors.deepPurple + : Colors.cyan[700]!, + ), + ); + }, + ); + }, + ), ), + ), + const SizedBox(height: 4), + // Duration Text + StreamBuilder( + stream: _audioPlayer.onPositionChanged, + builder: (context, snapshot) { + return FutureBuilder( + future: _audioPlayer.getDuration(), + builder: (context, durationSnapshot) { + String duration = '0:00'; + if (isCurrentlyPlaying && snapshot.hasData) { + duration = _formatDuration(snapshot.data!); + } else if (durationSnapshot.hasData) { + duration = _formatDuration(durationSnapshot.data!); + } + return Text( + duration, + style: TextStyle( + fontSize: 12, + color: msg.msgtype == 'sent' + ? Colors.deepPurple[700] + : Colors.cyan[900], + ), + ); + }, + ); + }, + ), ], ), ), @@ -198,35 +301,33 @@ class ChatPageState extends State { } Future _handleVoicePlayback(Msg msg) async { - final data = jsonDecode(msg.message); - final String filePath = data['filePath']; + try { + final data = jsonDecode(msg.message); + final String filePath = data['filePath']; - if (_currentlyPlayingId == msg.id && _isPlaying) { - await _audioPlayer.pause(); - setState(() => _isPlaying = false); - } else { - if (_currentlyPlayingId != msg.id) { - await _audioPlayer.stop(); - await _audioPlayer.play(DeviceFileSource(filePath)); + if (_currentlyPlayingId == msg.id && _isPlaying) { + await _audioPlayer.pause(); + setState(() => _isPlaying = false); } else { - await _audioPlayer.resume(); - } - setState(() { - _currentlyPlayingId = msg.id; - _isPlaying = true; - }); - } - - _audioPlayer.onPlayerComplete.listen((_) { - if (mounted) { + if (_currentlyPlayingId != msg.id) { + await _audioPlayer.stop(); + await _audioPlayer.setSource(DeviceFileSource(filePath)); + await _audioPlayer.resume(); + } else { + await _audioPlayer.resume(); + } setState(() { - _currentlyPlayingId = null; - _isPlaying = false; + _currentlyPlayingId = msg.id; + _isPlaying = true; }); } - }); + } catch (e) { + debugPrint('Error playing audio: $e'); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Error playing voice message')), + ); + } } - Widget _buildFileBubble(Msg msg) { dynamic data = jsonDecode(msg.message); if (data['type'] == 'voice') { From 89e0a9be3858ab62bfa428386deeb8190ed0f937 Mon Sep 17 00:00:00 2001 From: dikshantbhangala Date: Tue, 14 Jan 2025 22:58:53 +0530 Subject: [PATCH 15/16] Added chat history export with media support and UI improvements --- android/build.gradle | 11 ++++ android/gradle.properties | 1 + .../gradle/wrapper/gradle-wrapper.properties | 3 +- lib/encyption/rsa.dart | 59 +++++++++++++++++++ lib/pages/chat_page.dart | 13 +++- macos/Flutter/GeneratedPluginRegistrant.swift | 4 ++ pubspec.yaml | 12 +++- 7 files changed, 98 insertions(+), 5 deletions(-) diff --git a/android/build.gradle b/android/build.gradle index 70a1de5..9bb29bc 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -1,5 +1,16 @@ buildscript { ext.kotlin_version = '2.0.10' + + repositories { + google() + mavenCentral() + } + + dependencies { + // Add the Android Gradle Plugin classpath + classpath 'com.android.tools.build:gradle:8.1.0' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + } } allprojects { diff --git a/android/gradle.properties b/android/gradle.properties index 3b5b324..5a505fa 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,3 +1,4 @@ org.gradle.jvmargs=-Xmx4G -XX:+HeapDumpOnOutOfMemoryError android.useAndroidX=true android.enableJetifier=true +distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index e1ca574..9f7d524 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.3-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-all.zip + diff --git a/lib/encyption/rsa.dart b/lib/encyption/rsa.dart index 82ef802..bac4556 100644 --- a/lib/encyption/rsa.dart +++ b/lib/encyption/rsa.dart @@ -1,9 +1,16 @@ import 'dart:convert'; import 'dart:typed_data'; import 'package:asn1lib/asn1lib.dart'; +import 'package:pointycastle/api.dart'; import 'package:pointycastle/export.dart'; import 'package:pointycastle/random/fortuna_random.dart'; import 'dart:math'; +import 'dart:io'; +import 'package:path_provider/path_provider.dart'; +import 'package:pdf/pdf.dart'; +import 'package:pdf/widgets.dart' as pw; +import 'package:sqflite/sqflite.dart'; +import 'package:path/path.dart'; AsymmetricKeyPair generateRSAkeyPair(SecureRandom secureRandom, {int bitLength = 2048}) { final keyGen = RSAKeyGenerator() @@ -110,3 +117,55 @@ RSAPublicKey parsePublicKeyFromPem(String pem) { return RSAPublicKey(modulus, exponent); } +Future initDatabase() async { + final databasePath = await getDatabasesPath(); + final path = join(databasePath, 'chat_database.db'); + + return openDatabase( + path, + onCreate: (db, version) { + return db.execute( + 'CREATE TABLE messages(id INTEGER PRIMARY KEY, content TEXT, mediaPath TEXT)', + ); + }, + version: 1, + ); +} + +Future saveMessage(String message, {String? mediaPath}) async { + final db = await initDatabase(); + await db.insert( + 'messages', + {'content': message, 'mediaPath': mediaPath}, + conflictAlgorithm: ConflictAlgorithm.replace, + ); +} + +Future>> retrieveMessages() async { + final db = await initDatabase(); + return db.query('messages'); +} + +Future exportChatHistory() async { + final pdf = pw.Document(); + final messages = await retrieveMessages(); + + for (var message in messages) { + pdf.addPage(pw.Page(build: (pw.Context context) { + return pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Text(message['content']), + if (message['mediaPath'] != null) + pw.Text('Media: ${message['mediaPath']}'), + ], + ); + })); + } + + final directory = await getApplicationDocumentsDirectory(); + final filePath = '${directory.path}/chat_history.pdf'; + final file = File(filePath); + await file.writeAsBytes(await pdf.save()); + print('Chat history exported to: $filePath'); +} diff --git a/lib/pages/chat_page.dart b/lib/pages/chat_page.dart index 9eb4600..a94cb7e 100644 --- a/lib/pages/chat_page.dart +++ b/lib/pages/chat_page.dart @@ -9,7 +9,7 @@ import '../classes/global.dart'; import 'dart:convert'; import 'package:pointycastle/asymmetric/api.dart'; import '../components/view_file.dart'; -import '../encryption/rsa.dart'; +import '../encyption/rsa.dart'; class ChatPage extends StatefulWidget { String converser; @@ -60,6 +60,17 @@ class _ChatPageState extends State { return Scaffold( appBar: AppBar( title: Text(widget.converser), + actions: [ + IconButton( + icon: Icon(Icons.download), + onPressed: () async { + await exportChatHistory(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Chat history exported successfully!')), + ); + }, + ), + ], ), body: Column( children: [ diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 9d4b458..c1c43ce 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,14 +5,18 @@ import FlutterMacOS import Foundation +import audio_session import flutter_secure_storage_macos +import just_audio import local_auth_darwin import path_provider_foundation import shared_preferences_foundation import sqflite_darwin func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + AudioSessionPlugin.register(with: registry.registrar(forPlugin: "AudioSessionPlugin")) FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) + JustAudioPlugin.register(with: registry.registrar(forPlugin: "JustAudioPlugin")) FLALocalAuthPlugin.register(with: registry.registrar(forPlugin: "FLALocalAuthPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) diff --git a/pubspec.yaml b/pubspec.yaml index b67840c..d172e49 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -18,7 +18,6 @@ dependencies: flutter_nearby_connections: ^1.1.2 cupertino_icons: ^1.0.8 bubble: ^1.2.1 - nanoid: ^1.0.0 sqflite: ^2.3.3+1 intl: ^0.19.0 pointycastle: ^3.9.1 @@ -32,13 +31,20 @@ dependencies: open_filex: ^4.5.0 permission_handler: ^11.3.1 path_provider: ^2.1.4 - web_socket_channel: ^3.0.1 - flutter_sound: ^9.6.0 + web_socket_channel: ^2.2.0 + nanoid: ^1.0.0 + flutter_sound: any + just_audio: ^0.9.14 + pdf: ^3.11.1 + dev_dependencies: flutter_lints: flutter_test: sdk: flutter + + + flutter: uses-material-design: true \ No newline at end of file From a8a4e058460bb39d8b960a6e1fd545f771aee33c Mon Sep 17 00:00:00 2001 From: bhanu-dev82 Date: Wed, 15 Jan 2025 17:16:59 +0530 Subject: [PATCH 16/16] updated profile page and improved themes --- lib/pages/profile.dart | 537 +++++++++++++++++++--------- lib/providers/theme_colors.dart | 303 ++++++++++++++++ lib/providers/theme_components.dart | 102 ++++++ lib/providers/theme_provider.dart | 177 ++++++--- 4 files changed, 888 insertions(+), 231 deletions(-) create mode 100644 lib/providers/theme_colors.dart create mode 100644 lib/providers/theme_components.dart diff --git a/lib/pages/profile.dart b/lib/pages/profile.dart index 77bea6e..d6863eb 100644 --- a/lib/pages/profile.dart +++ b/lib/pages/profile.dart @@ -9,162 +9,181 @@ import 'home_screen.dart'; class Profile extends StatefulWidget { final bool onLogin; - const Profile({Key? key, required this.onLogin}) : super(key: key); + @override State createState() => _ProfileState(); } -class _ProfileState extends State { - TextEditingController myName = TextEditingController(); - bool loading = true; - var customLengthId = nanoid(6); +class _ProfileState extends State with SingleTickerProviderStateMixin { + final _formKey = GlobalKey(); + final _nameController = TextEditingController(); + late final AnimationController _animationController; + late final Animation _fadeAnimation; + + String _userId = ''; + bool _isLoading = true; + bool _isSaving = false; @override void initState() { super.initState(); - getDetails(); + _setupAnimations(); + _loadProfileData(); } - Future getDetails() async { + void _setupAnimations() { + _animationController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 800), + ); + _fadeAnimation = CurvedAnimation( + parent: _animationController, + curve: Curves.easeInOut, + ); + _animationController.forward(); + } + + Future _loadProfileData() async { try { final prefs = await SharedPreferences.getInstance(); final name = prefs.getString('p_name') ?? ''; - final id = prefs.getString('p_id') ?? ''; + final savedId = prefs.getString('p_id') ?? ''; if (mounted) { setState(() { - myName.text = name; - customLengthId = id.isNotEmpty ? id : customLengthId; + _nameController.text = name; + _userId = savedId.isNotEmpty ? savedId : nanoid(6); + _isLoading = false; }); - if (name.isNotEmpty && id.isNotEmpty && widget.onLogin) { - navigateToHomeScreen(); - } else { - setState(() { - loading = false; - }); + if (name.isNotEmpty && savedId.isNotEmpty && widget.onLogin) { + Global.myName = name; // Ensure name is set before navigation + _navigateToHome(); } } } catch (e) { - if (mounted) { - setState(() { - loading = false; - }); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Error loading profile: $e')), - ); - } + _handleError('Failed to load profile', e); } } - void navigateToHomeScreen() { - Global.myName = myName.text; + Future _saveProfile() async { + if (!_formKey.currentState!.validate()) return; + + setState(() => _isSaving = true); + try { + final prefs = await SharedPreferences.getInstance(); + final name = _nameController.text.trim(); + + await prefs.setString('p_name', name); + await prefs.setString('p_id', _userId); + + Global.myName = name; // Set global name before navigation + _navigateToHome(); + } catch (e) { + _handleError('Failed to save profile', e); + } finally { + if (mounted) setState(() => _isSaving = false); + } + } + + + void _navigateToHome() { if (!widget.onLogin) { Navigator.pop(context); } else { Navigator.pushReplacement( context, - MaterialPageRoute(builder: (context) => const HomeScreen()), + MaterialPageRoute(builder: (_) => const HomeScreen()), ); } } - Future saveProfile() async { - if (myName.text.trim().isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Please enter a valid name')), - ); - return; - } - - try { - final prefs = await SharedPreferences.getInstance(); - await prefs.setString('p_name', myName.text.trim()); - await prefs.setString('p_id', customLengthId); - navigateToHomeScreen(); - } catch (e) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Error saving profile: $e')), - ); - } + void _handleError(String message, dynamic error) { + if (!mounted) return; + setState(() => _isLoading = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('$message: $error'), + behavior: SnackBarBehavior.floating, + ), + ); } - Widget buildThemeSelector() { - return Consumer( - builder: (context, themeProvider, _) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0), - child: Text( - 'Theme Selection', - style: Theme.of(context).textTheme.titleMedium?.copyWith( + Widget _buildProfileCard() { + final colorScheme = Theme.of(context).colorScheme; + + return Card( + elevation: 8, + shadowColor: colorScheme.shadow.withOpacity(0.2), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + child: Padding( + padding: const EdgeInsets.all(24), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Profile Details', + style: Theme.of(context).textTheme.headlineSmall?.copyWith( fontWeight: FontWeight.bold, + color: colorScheme.primary, ), ), - ), - Container( - height: 120, - child: ListView.builder( - scrollDirection: Axis.horizontal, - itemCount: ThemeProvider.availableThemes.length, - itemBuilder: (context, index) { - String themeName = ThemeProvider.availableThemes.keys.elementAt(index); - bool isSelected = themeName == themeProvider.currentTheme; - - return Padding( - padding: const EdgeInsets.all(8.0), - child: Material( - elevation: isSelected ? 8 : 2, - borderRadius: BorderRadius.circular(12), - child: InkWell( - onTap: () => themeProvider.setTheme(themeName), - borderRadius: BorderRadius.circular(12), - child: Container( - width: 100, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - border: isSelected - ? Border.all( - color: Theme.of(context).colorScheme.primary, - width: 2, - ) - : null, + const SizedBox(height: 24), + TextFormField( + controller: _nameController, + validator: (value) => + value!.trim().isEmpty ? 'Name is required' : null, + decoration: InputDecoration( + labelText: 'Display Name', + hintText: 'Enter your display name', + prefixIcon: Icon(Icons.person_outline, + color: colorScheme.primary), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(15), + ), + ), + ), + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon(Icons.fingerprint, color: colorScheme.primary), + const SizedBox(width: 12), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Your Unique ID', + style: TextStyle( + color: colorScheme.onSurfaceVariant, ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - isSelected - ? Icons.check_circle - : Icons.brightness_auto, - color: ThemeProvider.availableThemes[themeName]!.primaryColor, - size: 32, - ), - const SizedBox(height: 8), - Text( - themeName, - style: TextStyle( - fontWeight: isSelected - ? FontWeight.bold - : FontWeight.normal, - ), - textAlign: TextAlign.center, - ), - ], + ), + Text( + _userId, + style: TextStyle( + fontWeight: FontWeight.bold, + color: colorScheme.primary, ), ), - ), + ], ), - ); - }, + ], + ), ), - ), - ], - ); - }, + ], + ), + ), + ), ); } @@ -172,84 +191,246 @@ class _ProfileState extends State { Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: const Text('Profile'), - elevation: 0, + title: const Text('Profile Setup'), + centerTitle: true, ), - body: loading + body: _isLoading ? const Center(child: CircularProgressIndicator()) - : SingleChildScrollView( - child: SafeArea( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Card( - elevation: 4, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(15), - ), - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Your Profile', - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(height: 16), - TextFormField( - controller: myName, - decoration: InputDecoration( - labelText: 'Name', - hintText: 'What do people call you?', - prefixIcon: const Icon(Icons.person), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - ), - filled: true, - ), - ), - const SizedBox(height: 12), - Text( - 'Your ID: $customLengthId', - style: Theme.of(context).textTheme.bodyLarge, - ), - ], - ), - ), + : FadeTransition( + opacity: _fadeAnimation, + child: CustomScrollView( + slivers: [ + SliverFillRemaining( + hasScrollBody: false, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + _buildProfileCard(), + const SizedBox(height: 20), + const ThemeSelector(), + // Minimum spacing that ensures button visibility + SizedBox(height: MediaQuery.of(context).size.height * 0.08), + _buildSaveButton(), + // Safe area padding for bottom + SizedBox(height: MediaQuery.of(context).padding.bottom + 16), + ], ), - const SizedBox(height: 24), - buildThemeSelector(), - const SizedBox(height: 24), - ElevatedButton( - onPressed: saveProfile, - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - child: const Text( - 'Save Profile', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ), - ), - ], + ), ), + ], + ), + ), + ); + } + + + Widget _buildSaveButton() { + return SizedBox( + width: double.infinity, + height: 56, + child: ElevatedButton( + onPressed: _isSaving ? null : _saveProfile, + style: ElevatedButton.styleFrom( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), ), ), + child: _isSaving + ? const SizedBox( + height: 24, + width: 24, + child: CircularProgressIndicator(), + ) + : const Text( + 'Save Profile', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), ), ); } @override void dispose() { - myName.dispose(); + _nameController.dispose(); + _animationController.dispose(); super.dispose(); } +} +class ThemeSelector extends StatelessWidget { + const ThemeSelector({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Padding( + padding: EdgeInsets.all(16.0), + child: Text( + 'Choose Base Theme', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + ), + BaseThemeSelector(), + + const SizedBox(height: 24), + + const Padding( + padding: EdgeInsets.all(16.0), + child: Text( + 'Choose Color Scheme (Optional)', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + ), + ColorSchemeSelector(), + ], + ); + } +} + +class BaseThemeSelector extends StatelessWidget { + const BaseThemeSelector({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + final themeProvider = Provider.of(context); + + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: ThemeProvider.baseThemes.keys.map((themeName) { + final isSelected = themeName == themeProvider.baseTheme; + + return Padding( + padding: const EdgeInsets.only(right: 12), + child: ThemeOption( + name: themeName, + isSelected: isSelected, + onTap: () => themeProvider.setBaseTheme(themeName), + color: themeName == 'Light' + ? Colors.blue.shade100 + : Colors.grey.shade800, + secondaryColor: themeName == 'Light' + ? Colors.blue.shade200 + : Colors.grey.shade900, + ), + + ); + }).toList(), + ), + ); + } +} + +class ColorSchemeSelector extends StatelessWidget { + const ColorSchemeSelector({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + final themeProvider = Provider.of(context); + final isDark = Theme.of(context).brightness == Brightness.dark; + + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: ThemeProvider.colorSchemes.entries.map((entry) { + final isSelected = entry.key == themeProvider.colorSchemeName; + final scheme = isDark + ? ThemeProvider.getDarkScheme(entry.key) + : entry.value; + + return Padding( + padding: const EdgeInsets.only(right: 12), + child: ThemeOption( + name: entry.key, + isSelected: isSelected, + onTap: () => themeProvider.setColorScheme(entry.key), + color: scheme.primary, + secondaryColor: scheme.secondary, + ), + ); + }).toList(), + ), + ); + } +} + +class ThemeOption extends StatelessWidget { + final String name; + final bool isSelected; + final VoidCallback onTap; + final Color color; + final Color secondaryColor; + + const ThemeOption({ + Key? key, + required this.name, + required this.isSelected, + required this.onTap, + required this.color, + required this.secondaryColor, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme + .of(context) + .colorScheme; + + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [color, secondaryColor], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isSelected ? colorScheme.primary : Colors.transparent, + width: 2, + ), + boxShadow: isSelected ? [ + BoxShadow( + color: colorScheme.shadow.withOpacity(0.2), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ] : null, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + name, + style: TextStyle( + fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, + color: color.computeLuminance() > 0.5 ? Colors.black : Colors + .white, + ), + ), + if (isSelected) + Icon( + Icons.check_circle, + size: 16, + color: color.computeLuminance() > 0.5 ? Colors.black : Colors + .white, + ), + ], + ), + ), + ); + } + } \ No newline at end of file diff --git a/lib/providers/theme_colors.dart b/lib/providers/theme_colors.dart new file mode 100644 index 0000000..ca285d8 --- /dev/null +++ b/lib/providers/theme_colors.dart @@ -0,0 +1,303 @@ +import 'package:flutter/material.dart'; + +class ThemeColors { + // Extended color properties for all schemes + + // Light Theme Colors + static final lightColorScheme = ColorScheme.light( + primary: const Color(0xFF0061A4), + onPrimary: const Color(0xFFFFFFFF), + primaryContainer: const Color(0xFFD1E4FF), + onPrimaryContainer: const Color(0xFF001D36), + secondary: const Color(0xFF535F70), + onSecondary: const Color(0xFFFFFFFF), + secondaryContainer: const Color(0xFFD7E3F7), + onSecondaryContainer: const Color(0xFF101C2B), + surface: const Color(0xFFFBFCFF), + onSurface: const Color(0xFF1A1C1E), + surfaceContainerHighest: const Color(0xFFDFE2EB), + onSurfaceVariant: const Color(0xFF43474E), + // Add error colors for form validation + error: const Color(0xFFBA1A1A), + onError: const Color(0xFFFFFFFF), + errorContainer: const Color(0xFFFFDAD6), + onErrorContainer: const Color(0xFF410002), + // Add shadow color for elevation + shadow: Colors.black.withOpacity(0.1), + ); + + // Dark Theme Colors + static final darkColorScheme = ColorScheme.dark( + primary: const Color(0xFF9ECAFF), + onPrimary: const Color(0xFF003258), + primaryContainer: const Color(0xFF00497D), + onPrimaryContainer: const Color(0xFFD1E4FF), + secondary: const Color(0xFFBBC7DB), + onSecondary: const Color(0xFF253140), + secondaryContainer: const Color(0xFF3B4858), + onSecondaryContainer: const Color(0xFFD7E3F7), + surface: const Color(0xFF1A1C1E), + onSurface: const Color(0xFFE2E2E6), + surfaceContainerHighest: const Color(0xFF43474E), + onSurfaceVariant: const Color(0xFFC3C7CF), + // Add error colors for form validation + error: const Color(0xFFFFB4AB), + onError: const Color(0xFF690005), + errorContainer: const Color(0xFF93000A), + onErrorContainer: const Color(0xFFFFDAD6), + // Add shadow color for elevation + shadow: Colors.black.withOpacity(0.3), + ); + + // Nature Theme Colors + static final natureColorScheme = ColorScheme.light( + primary: const Color(0xFF246C2C), + onPrimary: const Color(0xFFFFFFFF), + primaryContainer: const Color(0xFFA8F5A4), + onPrimaryContainer: const Color(0xFF002204), + secondary: const Color(0xFF52634F), + onSecondary: const Color(0xFFFFFFFF), + secondaryContainer: const Color(0xFFD5E8CF), + onSecondaryContainer: const Color(0xFF101F0F), + surface: const Color(0xFFFBFDF7), + onSurface: const Color(0xFF1A1C19), + surfaceContainerHighest: const Color(0xFFDEE5D9), + onSurfaceVariant: const Color(0xFF424940), + error: const Color(0xFFBA1A1A), + onError: const Color(0xFFFFFFFF), + errorContainer: const Color(0xFFFFDAD6), + onErrorContainer: const Color(0xFF410002), + shadow: Colors.black.withOpacity(0.1), + ); + + static final natureDarkColorScheme = ColorScheme.dark( + primary: const Color(0xFF8CD889), + onPrimary: const Color(0xFF003909), + primaryContainer: const Color(0xFF165219), + onPrimaryContainer: const Color(0xFFA8F5A4), + secondary: const Color(0xFFB9CCB4), + onSecondary: const Color(0xFF263423), + secondaryContainer: const Color(0xFF3C4B38), + onSecondaryContainer: const Color(0xFFD5E8CF), + surface: const Color(0xFF1A1C19), + onSurface: const Color(0xFFE2E3DE), + surfaceContainerHighest: const Color(0xFF424940), + onSurfaceVariant: const Color(0xFFC2C9BE), + error: const Color(0xFFFFB4AB), + onError: const Color(0xFF690005), + errorContainer: const Color(0xFF93000A), + onErrorContainer: const Color(0xFFFFDAD6), + shadow: Colors.black.withOpacity(0.3), + ); + + // Amber Theme Colors + static final amberColorScheme = ColorScheme.light( + primary: const Color(0xFFFFA726), + onPrimary: const Color(0xFF000000), + primaryContainer: const Color(0xFFFFECB3), + onPrimaryContainer: const Color(0xFF261900), + secondary: const Color(0xFFFFB74D), + onSecondary: const Color(0xFF000000), + secondaryContainer: const Color(0xFFFFF3E0), + onSecondaryContainer: const Color(0xFF261900), + surface: const Color(0xFFFFFBF5), + onSurface: const Color(0xFF1A1A1A), + surfaceContainerHighest: const Color(0xFFFFEFD5), + onSurfaceVariant: const Color(0xFF4E4B40), + error: const Color(0xFFBA1A1A), + onError: const Color(0xFFFFFFFF), + errorContainer: const Color(0xFFFFDAD6), + onErrorContainer: const Color(0xFF410002), + shadow: Colors.black.withOpacity(0.1), + ); + + static final amberDarkColorScheme = ColorScheme.dark( + primary: const Color(0xFFFFD180), + onPrimary: const Color(0xFF2B1700), + primaryContainer: const Color(0xFFFF9800), + onPrimaryContainer: const Color(0xFFFFECCC), + secondary: const Color(0xFFFFE0B2), + onSecondary: const Color(0xFF261500), + secondaryContainer: const Color(0xFFE65100), + onSecondaryContainer: const Color(0xFFFFF4E6), + surface: const Color(0xFF121212), + onSurface: const Color(0xFFFAFAFA), + surfaceContainerHighest: const Color(0xFF3D3833), + onSurfaceVariant: const Color(0xFFE8DED2), + error: const Color(0xFFFF8A80), + onError: const Color(0xFF480000), + errorContainer: const Color(0xFFB71C1C), + onErrorContainer: const Color(0xFFFFEBEE), + shadow: Colors.black.withOpacity(0.4), + ); + + + + // Rose Theme Colors + static final roseColorScheme = ColorScheme.light( + primary: const Color(0xFFE84A5F), + onPrimary: const Color(0xFFFFFFFF), + primaryContainer: const Color(0xFFFFE4E8), + onPrimaryContainer: const Color(0xFF400012), + secondary: const Color(0xFF9D8189), + onSecondary: const Color(0xFFFFFFFF), + secondaryContainer: const Color(0xFFFFD8E4), + onSecondaryContainer: const Color(0xFF2E1519), + surface: const Color(0xFFFFF5F7), + onSurface: const Color(0xFF1A1A1A), + surfaceContainerHighest: const Color(0xFFFFECF1), + onSurfaceVariant: const Color(0xFF534346), + error: const Color(0xFFBA1A1A), + onError: const Color(0xFFFFFFFF), + errorContainer: const Color(0xFFFFDAD6), + onErrorContainer: const Color(0xFF410002), + shadow: Colors.black.withOpacity(0.1), + ); + + static final roseDarkColorScheme = ColorScheme.dark( + primary: const Color(0xFFFF8FA3), + onPrimary: const Color(0xFF4A0012), + primaryContainer: const Color(0xFFB4364A), + onPrimaryContainer: const Color(0xFFFFE4E8), + secondary: const Color(0xFFD1A0AA), + onSecondary: const Color(0xFF2B1419), + secondaryContainer: const Color(0xFF432931), + onSecondaryContainer: const Color(0xFFFFD8E4), + surface: const Color(0xFF151111), + onSurface: const Color(0xFFE8E0E1), + surfaceContainerHighest: const Color(0xFF3D2F32), + onSurfaceVariant: const Color(0xFFD6C2C7), + error: const Color(0xFFFFB4AB), + onError: const Color(0xFF690005), + errorContainer: const Color(0xFF93000A), + onErrorContainer: const Color(0xFFFFDAD6), + shadow: Colors.black.withOpacity(0.3), + ); + + + // Bubblegum Theme Colors + static final bubblegumColorScheme = ColorScheme.light( + primary: const Color(0xFFFF69B4), + onPrimary: const Color(0xFFFFFFFF), + primaryContainer: const Color(0xFFFFD6E9), + onPrimaryContainer: const Color(0xFF3F0020), + secondary: const Color(0xFFFF9ECD), + onSecondary: const Color(0xFF000000), + secondaryContainer: const Color(0xFFFFE3F1), + onSecondaryContainer: const Color(0xFF3F002D), + surface: const Color(0xFFFFF5F9), + onSurface: const Color(0xFF1A1A1A), + surfaceContainerHighest: const Color(0xFFFFE6F3), + onSurfaceVariant: const Color(0xFF534347), + error: const Color(0xFFBA1A1A), + onError: const Color(0xFFFFFFFF), + errorContainer: const Color(0xFFFFDAD6), + onErrorContainer: const Color(0xFF410002), + shadow: Colors.black.withOpacity(0.1), + ); + + static final bubblegumDarkColorScheme = ColorScheme.dark( + primary: const Color(0xFFFF80CE), + onPrimary: const Color(0xFF3B0026), + primaryContainer: const Color(0xFFD4458B), + onPrimaryContainer: const Color(0xFFFFD6E9), + secondary: const Color(0xFFFF9ED2), + onSecondary: const Color(0xFF330024), + secondaryContainer: const Color(0xFF8B2E63), + onSecondaryContainer: const Color(0xFFFFE3F1), + surface: const Color(0xFF120D0F), + onSurface: const Color(0xFFE8E0E4), + surfaceContainerHighest: const Color(0xFF3D2934), + onSurfaceVariant: const Color(0xFFD6C2C8), + error: const Color(0xFFFFB4AB), + onError: const Color(0xFF690005), + errorContainer: const Color(0xFF93000A), + onErrorContainer: const Color(0xFFFFDAD6), + shadow: Colors.black.withOpacity(0.3), + ); + + // Lavender Theme + static final lavenderColorScheme = ColorScheme.light( + primary: const Color(0xFF9575CD), + onPrimary: const Color(0xFFFFFFFF), + primaryContainer: const Color(0xFFEDE7F6), + onPrimaryContainer: const Color(0xFF1B0057), + secondary: const Color(0xFFB39DDB), + onSecondary: const Color(0xFF000000), + secondaryContainer: const Color(0xFFF3E5F5), + onSecondaryContainer: const Color(0xFF2A0049), + surface: const Color(0xFFFCF8FF), + onSurface: const Color(0xFF1A191C), + surfaceContainerHighest: const Color(0xFFE8E0F0), + onSurfaceVariant: const Color(0xFF49454E), + error: const Color(0xFFBA1A1A), + onError: const Color(0xFFFFFFFF), + errorContainer: const Color(0xFFFFDAD6), + onErrorContainer: const Color(0xFF410002), + shadow: Colors.black.withOpacity(0.1), + ); + + static final lavenderDarkColorScheme = ColorScheme.dark( + primary: const Color(0xFFB39DDB), + onPrimary: const Color(0xFF2A004C), + primaryContainer: const Color(0xFF6A3AA7), + onPrimaryContainer: const Color(0xFFEDE7F6), + secondary: const Color(0xFFD1C4E9), + onSecondary: const Color(0xFF1D0033), + secondaryContainer: const Color(0xFF4527A0), + onSecondaryContainer: const Color(0xFFF3E5F5), + surface: const Color(0xFF120F17), + onSurface: const Color(0xFFE6E1E6), + surfaceContainerHighest: const Color(0xFF332D3F), + onSurfaceVariant: const Color(0xFFCBC4CE), + error: const Color(0xFFFFB4AB), + onError: const Color(0xFF690005), + errorContainer: const Color(0xFF93000A), + onErrorContainer: const Color(0xFFFFDAD6), + shadow: Colors.black.withOpacity(0.3), + ); + +// Neon Theme + static final neonColorScheme = ColorScheme.light( + primary: const Color(0xFF00CC7D), + onPrimary: const Color(0xFF000000), + primaryContainer: const Color(0xFF80FFB9), + onPrimaryContainer: const Color(0xFF002117), + secondary: const Color(0xFF00B8C4), + onSecondary: const Color(0xFF000000), + secondaryContainer: const Color(0xFF80F4FF), + onSecondaryContainer: const Color(0xFF002022), + surface: const Color(0xFFECF4F2), + onSurface: const Color(0xFF121212), + surfaceContainerHighest: const Color(0xFFD8E8E4), + onSurfaceVariant: const Color(0xFF1F2625), + error: const Color(0xFFE60052), + onError: const Color(0xFFFFFFFF), + errorContainer: const Color(0xFFFFB3CB), + onErrorContainer: const Color(0xFF400016), + shadow: Colors.black.withOpacity(0.2), + ); + + + + static final neonDarkColorScheme = ColorScheme.dark( + primary: const Color(0xFF00FF9C), + onPrimary: const Color(0xFF001A12), + primaryContainer: const Color(0xFF00995D), + onPrimaryContainer: const Color(0xFFB3FFD6), + secondary: const Color(0xFF00F3FF), + onSecondary: const Color(0xFF001618), + secondaryContainer: const Color(0xFF009199), + onSecondaryContainer: const Color(0xFFB3FCFF), + surface: const Color(0xFF050505), + onSurface: const Color(0xFFE0E0E0), + surfaceContainerHighest: const Color(0xFF1A1A1A), + onSurfaceVariant: const Color(0xFFCACACA), + error: const Color(0xFFFF0059), + onError: const Color(0xFFFFFFFF), + errorContainer: const Color(0xFF99003D), + onErrorContainer: const Color(0xFFFFB3CB), + shadow: Colors.black.withOpacity(0.4), + ); + +} diff --git a/lib/providers/theme_components.dart b/lib/providers/theme_components.dart new file mode 100644 index 0000000..35d6ea1 --- /dev/null +++ b/lib/providers/theme_components.dart @@ -0,0 +1,102 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +class ThemeComponents { + // Common border radius values + static const double _inputBorderRadius = 12.0; + static const double _buttonBorderRadius = 12.0; + static const double _cardBorderRadius = 16.0; + + // Common padding values + static const EdgeInsets _inputPadding = EdgeInsets.symmetric(horizontal: 16, vertical: 12); + static const EdgeInsets _buttonPadding = EdgeInsets.symmetric(horizontal: 24, vertical: 12); + + static InputDecorationTheme inputDecorationTheme(ColorScheme colors) { + return InputDecorationTheme( + filled: true, + fillColor: colors.surfaceContainerHighest.withOpacity(0.5), + contentPadding: _inputPadding, + border: _buildInputBorder(_inputBorderRadius), + enabledBorder: _buildInputBorder( + _inputBorderRadius, + borderColor: colors.outline.withOpacity(0.3), + ), + focusedBorder: _buildInputBorder( + _inputBorderRadius, + borderColor: colors.primary, + width: 2, + ), + errorBorder: _buildInputBorder( + _inputBorderRadius, + borderColor: colors.error, + ), + // Add focused error border for consistency + focusedErrorBorder: _buildInputBorder( + _inputBorderRadius, + borderColor: colors.error, + width: 2, + ), + ); + } + + static ElevatedButtonThemeData elevatedButtonTheme(ColorScheme colors) { + return ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + elevation: 0, + padding: _buttonPadding, + backgroundColor: colors.primary, + foregroundColor: colors.onPrimary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(_buttonBorderRadius), + ), + // Add disabled colors for better accessibility + disabledBackgroundColor: colors.onSurface.withOpacity(0.12), + disabledForegroundColor: colors.onSurface.withOpacity(0.38), + ), + ); + } + + static CardTheme cardTheme(ColorScheme colors) { + return CardTheme( + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(_cardBorderRadius), + ), + color: colors.surface, + clipBehavior: Clip.antiAlias, + // Add shadow color for better dark mode appearance + shadowColor: colors.shadow, + ); + } + + static AppBarTheme appBarTheme(ColorScheme colors) { + return AppBarTheme( + elevation: 0, + centerTitle: true, + backgroundColor: colors.surface, + foregroundColor: colors.onSurface, + iconTheme: IconThemeData(color: colors.onSurface), + titleTextStyle: TextStyle( + color: colors.onSurface, + fontSize: 20, + fontWeight: FontWeight.w600, + ), + // Add system overlay style for better status bar visibility + systemOverlayStyle: colors.brightness == Brightness.dark + ? SystemUiOverlayStyle.light + : SystemUiOverlayStyle.dark, + ); + } + + // Helper method to build input borders + static OutlineInputBorder _buildInputBorder( + double radius, { + Color borderColor = Colors.transparent, + double width = 1, + }) { + return OutlineInputBorder( + borderRadius: BorderRadius.circular(radius), + borderSide: BorderSide(color: borderColor, width: width), + ); + } +} \ No newline at end of file diff --git a/lib/providers/theme_provider.dart b/lib/providers/theme_provider.dart index 25b557b..bb1ad99 100644 --- a/lib/providers/theme_provider.dart +++ b/lib/providers/theme_provider.dart @@ -1,76 +1,147 @@ - import 'package:flutter/material.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'theme_colors.dart'; +import 'theme_components.dart'; class ThemeProvider with ChangeNotifier { - static const String _themePreferenceKey = 'selected_theme'; - - // Define available themes - static final Map availableThemes = { - 'Light': ThemeData( - brightness: Brightness.light, - primaryColor: Colors.blue, - colorScheme: ColorScheme.light( - primary: Colors.blue, - secondary: Colors.blueAccent, - ), - ), - 'Dark': ThemeData( - brightness: Brightness.dark, - primaryColor: Colors.blueGrey[900], - colorScheme: ColorScheme.dark( - primary: Colors.blueGrey[900]!, - secondary: Colors.blueAccent, - ), - ), - 'Nature': ThemeData( - brightness: Brightness.light, - primaryColor: Colors.green, - colorScheme: ColorScheme.light( - primary: Colors.green, - secondary: Colors.lightGreen, - ), - ), - 'Ocean': ThemeData( - brightness: Brightness.light, - primaryColor: Colors.cyan, - colorScheme: ColorScheme.light( - primary: Colors.cyan, - secondary: Colors.lightBlue, - ), - ), - 'Sunset': ThemeData( - brightness: Brightness.light, - primaryColor: Colors.orange, - colorScheme: ColorScheme.light( - primary: Colors.orange, - secondary: Colors.deepOrange, - ), - ), + static const String baseThemeKey = 'base_theme'; + static const String colorSchemeKey = 'color_scheme'; + + // Base themes (Light/Dark) + static final Map baseThemes = { + 'Light': buildTheme(ThemeColors.lightColorScheme), + 'Dark': buildTheme(ThemeColors.darkColorScheme), }; - String _currentTheme = 'Light'; + // Color schemes that can be applied to either base theme + static final Map colorSchemes = { + 'Default': ThemeColors.lightColorScheme, + 'Neon': ThemeColors.neonColorScheme, + 'Amber': ThemeColors.amberColorScheme, + 'Bubblegum': ThemeColors.bubblegumColorScheme, + 'Lavender': ThemeColors.lavenderColorScheme, + 'Rose': ThemeColors.roseColorScheme, + 'Nature': ThemeColors.natureColorScheme, + }; + + + + String _baseTheme = 'Light'; + String _colorScheme = 'Default'; ThemeProvider() { loadTheme(); } - String get currentTheme => _currentTheme; - ThemeData get theme => availableThemes[_currentTheme]!; + String get baseTheme => _baseTheme; + String get colorSchemeName => _colorScheme; + + // Get the current theme data + // Get the current theme data + ThemeData get theme { + if (_colorScheme == 'Default') { + // Use base theme directly + return baseThemes[_baseTheme]!; + } else { + // Get the appropriate color scheme based on base theme + ColorScheme customScheme = _baseTheme == 'Dark' + ? getDarkScheme(_colorScheme) + : colorSchemes[_colorScheme]!; + + return buildTheme(customScheme); + } + } + + + + static ThemeData buildTheme(ColorScheme colorScheme) { + return ThemeData( + useMaterial3: true, + colorScheme: colorScheme, + brightness: colorScheme.brightness, + // Component Themes + inputDecorationTheme: ThemeComponents.inputDecorationTheme(colorScheme), + elevatedButtonTheme: ThemeComponents.elevatedButtonTheme(colorScheme), + cardTheme: ThemeComponents.cardTheme(colorScheme), + appBarTheme: ThemeComponents.appBarTheme(colorScheme), + // Dialog Theme + dialogTheme: DialogTheme( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + backgroundColor: colorScheme.surface, + elevation: 3, + ), + // Bottom Sheet Theme + bottomSheetTheme: BottomSheetThemeData( + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + backgroundColor: colorScheme.surface, + ), + // List Tile Theme + listTileTheme: ListTileThemeData( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + tileColor: colorScheme.surface, + iconColor: colorScheme.primary, + ), + // Floating Action Button Theme + floatingActionButtonTheme: FloatingActionButtonThemeData( + backgroundColor: colorScheme.primaryContainer, + foregroundColor: colorScheme.onPrimaryContainer, + elevation: 4, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + ), + // Snackbar Theme + snackBarTheme: SnackBarThemeData( + behavior: SnackBarBehavior.floating, + backgroundColor: colorScheme.inverseSurface, + contentTextStyle: TextStyle(color: colorScheme.onInverseSurface), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + ); + } Future loadTheme() async { final prefs = await SharedPreferences.getInstance(); - _currentTheme = prefs.getString(_themePreferenceKey) ?? 'Light'; + _baseTheme = prefs.getString(baseThemeKey) ?? 'Light'; + _colorScheme = prefs.getString(colorSchemeKey) ?? 'Default'; notifyListeners(); } - Future setTheme(String themeName) async { - if (!availableThemes.containsKey(themeName)) return; + Future setBaseTheme(String themeName) async { + if (!baseThemes.containsKey(themeName)) return; final prefs = await SharedPreferences.getInstance(); - await prefs.setString(_themePreferenceKey, themeName); + await prefs.setString(baseThemeKey, themeName); + _baseTheme = themeName; + notifyListeners(); + } + + Future setColorScheme(String schemeName) async { + if (!colorSchemes.containsKey(schemeName)) return; - _currentTheme = themeName; + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(colorSchemeKey, schemeName); + _colorScheme = schemeName; notifyListeners(); } + static ColorScheme getDarkScheme(String name) { + switch (name) { + case 'Nature': + return ThemeColors.natureDarkColorScheme; + case 'Amber': + return ThemeColors.amberDarkColorScheme; + case 'Rose': + return ThemeColors.roseDarkColorScheme; + case 'Bubblegum': + return ThemeColors.bubblegumDarkColorScheme; + case 'Lavender': + return ThemeColors.lavenderDarkColorScheme; + case 'Neon': + return ThemeColors.neonDarkColorScheme; + default: + return ThemeColors.darkColorScheme; + } + } + } \ No newline at end of file