From 584306ef4ded035c99bb906abe056c48f657e0a8 Mon Sep 17 00:00:00 2001 From: TriptiMirani Date: Thu, 20 Feb 2025 23:36:51 +0530 Subject: [PATCH] Fixes #27: Added a download button on top-right corner of chat_page which then prompts the user to view the pdf from any app we want or share it through any medium we want --- lib/components/chat_export.dart | 281 ++++++++++++++++++++++++++++++++ lib/pages/chat_page.dart | 253 ++++++++++++++++++---------- pubspec.yaml | 3 + 3 files changed, 453 insertions(+), 84 deletions(-) create mode 100644 lib/components/chat_export.dart diff --git a/lib/components/chat_export.dart b/lib/components/chat_export.dart new file mode 100644 index 0000000..2c6c8d7 --- /dev/null +++ b/lib/components/chat_export.dart @@ -0,0 +1,281 @@ +import 'dart:io'; +import 'dart:convert'; +import 'dart:typed_data'; +import 'package:flutter/material.dart'; +import 'package:pdf/pdf.dart'; +import 'package:pdf/widgets.dart' as pw; +import 'package:path_provider/path_provider.dart'; +import 'package:sqflite/sqflite.dart'; +import 'package:path/path.dart'; +import 'package:printing/printing.dart'; +import 'package:open_filex/open_filex.dart'; +import 'package:share_plus/share_plus.dart'; +import '../database/model.dart'; +import '../classes/global.dart'; +import '../encyption/rsa.dart'; +import 'package:pointycastle/asymmetric/api.dart'; + +Future>> getChatHistory(String converser) async { + Database? db; + try { + final dbPath = await getDatabasesPath(); + final path = join(dbPath, 'p2p.db'); + + db = await openDatabase(path); + + final List> messages = await db.query( + conversationsTableName, + where: 'converser = ?', + whereArgs: [converser], + orderBy: "timestamp ASC", + columns: ['_id', 'converser', 'type', 'msg', 'timestamp', 'ack'] + ); + + // Decrypt messages + return messages.map((msg) { + try { + if (Global.myPrivateKey != null) { + RSAPrivateKey privateKey = Global.myPrivateKey!; + dynamic data = jsonDecode(msg['msg']); + + if (data['type'] == 'text') { + Uint8List encryptedBytes = base64Decode(data['data']); + Uint8List decryptedBytes = rsaDecrypt(privateKey, encryptedBytes); + String decryptedMessage = utf8.decode(decryptedBytes); + return { + ...msg, + 'decryptedMsg': decryptedMessage, + 'messageType': data['type'] + }; + } else if (data['type'] == 'file' || data['type'] == 'voice') { + // Handle file messages + return { + ...msg, + 'decryptedMsg': 'File: ${data['fileName'] ?? 'Unnamed file'}', + 'messageType': data['type'] + }; + } + } + } catch (e) { + debugPrint('Decryption error for message: $e'); + } + return { + ...msg, + 'decryptedMsg': 'Unable to decrypt message', + 'messageType': 'unknown' + }; + }).toList(); + } catch (e) { + debugPrint('Database error: $e'); + debugPrint('Stack trace: ${StackTrace.current}'); + return []; + } finally { + await db?.close(); + } +} + +Future generatePdf({required String converser}) async { + try { + final pdf = pw.Document(); + final chatHistory = await getChatHistory(converser); + + pdf.addPage( + pw.Page( + margin: const pw.EdgeInsets.all(32), + build: (pw.Context context) { + return pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Text( + "${Global.myName}'s Chat History with $converser", + style: pw.TextStyle( + fontSize: 18, + fontWeight: pw.FontWeight.bold, + ), + ), + pw.SizedBox(height: 20), + if (chatHistory.isEmpty) + pw.Text( + "No messages found", + style: pw.TextStyle( + fontSize: 14, + color: PdfColors.grey, + ), + ) + else + ...chatHistory.map((chat) { + final DateTime timestamp = DateTime.parse(chat['timestamp']); + final String formattedTime = + "${timestamp.hour.toString().padLeft(2, '0')}:${timestamp.minute.toString().padLeft(2, '0')}"; + final String formattedDate = + "${timestamp.day}/${timestamp.month}/${timestamp.year}"; + + final bool isSent = chat['type'] == 'sent'; + + return pw.Column( + crossAxisAlignment: isSent + ? pw.CrossAxisAlignment.end + : pw.CrossAxisAlignment.start, + children: [ + pw.Padding( + padding: const pw.EdgeInsets.only(bottom: 4), + child: pw.Text( + "$formattedDate $formattedTime", + style: pw.TextStyle( + fontSize: 10, + color: PdfColors.grey600, + ), + ), + ), + pw.Container( + width: 300, + padding: const pw.EdgeInsets.all(12), + decoration: pw.BoxDecoration( + color: isSent ? PdfColors.blue50 : PdfColors.grey100, + borderRadius: pw.BorderRadius.circular(12), + ), + child: pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + if (chat['messageType'] == 'file' || chat['messageType'] == 'voice') + pw.Row( + children: [ + pw.Text( + "📎 ", + style: pw.TextStyle(fontSize: 12), + ), + pw.Text( + chat['decryptedMsg'], + style: pw.TextStyle(fontSize: 12), + ), + ], + ) + else + pw.Text( + chat['decryptedMsg'], + style: pw.TextStyle(fontSize: 12), + ), + ], + ), + ), + pw.SizedBox(height: 12), + ], + ); + }).toList(), + ], + ); + }, + ), + ); + + final directory = await getApplicationDocumentsDirectory(); + final filePath = '${directory.path}/chat_history_${DateTime.now().millisecondsSinceEpoch}.pdf'; + final file = File(filePath); + await file.writeAsBytes(await pdf.save()); + return file; + } catch (e) { + debugPrint('PDF generation error: $e'); + debugPrint('Stack trace: ${StackTrace.current}'); + rethrow; + } +} + +// Print the PDF +Future printPdf({required String converser}) async { + try { + final file = await generatePdf(converser: converser); + await Printing.layoutPdf( + onLayout: (PdfPageFormat format) async => file.readAsBytes(), + ); + } catch (e) { + debugPrint('Print error: $e'); + rethrow; + } +} + +// Open the PDF in a viewer +Future openPdf({required String converser}) async { + try { + final file = await generatePdf(converser: converser); + await OpenFilex.open(file.path); + } catch (e) { + debugPrint('Open PDF error: $e'); + rethrow; + } +} + +// Share the PDF via apps like WhatsApp, Gmail, etc. +Future sharePdf({required String converser}) async { + try { + final file = await generatePdf(converser: converser); + await Share.shareXFiles( + [XFile(file.path)], + text: "Chat History with $converser", + ); + } catch (e) { + debugPrint('Share error: $e'); + rethrow; + } +} + +// UI to trigger actions +class ChatExportScreen extends StatelessWidget { + final String converser; + + const ChatExportScreen({ + Key? key, + required this.converser, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text("Export Chat - $converser"), + ), + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ElevatedButton( + onPressed: () => + printPdf(converser: converser).catchError((error) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Failed to print chat. Please try again.'), + backgroundColor: Colors.red, + ), + ); + }), + child: const Text("Print Chat History"), + ), + ElevatedButton( + onPressed: () => + openPdf(converser: converser).catchError((error) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Failed to open PDF. Please try again.'), + backgroundColor: Colors.red, + ), + ); + }), + child: const Text("Open PDF"), + ), + ElevatedButton( + onPressed: () => + sharePdf(converser: converser).catchError((error) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Failed to share PDF. Please try again.'), + backgroundColor: Colors.red, + ), + ); + }), + child: const Text("Share PDF"), + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/chat_page.dart b/lib/pages/chat_page.dart index 71f2265..790cc33 100644 --- a/lib/pages/chat_page.dart +++ b/lib/pages/chat_page.dart @@ -1,5 +1,5 @@ import 'dart:typed_data'; - +import '../components/chat_export.dart'; // Import the export function import 'package:bubble/bubble.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; @@ -12,6 +12,8 @@ import 'package:pointycastle/asymmetric/api.dart'; import '../components/view_file.dart'; import '../encyption/rsa.dart'; import 'package:audioplayers/audioplayers.dart'; +import 'package:open_filex/open_filex.dart'; +import 'package:share_plus/share_plus.dart'; class ChatPage extends StatefulWidget { const ChatPage({Key? key, required this.converser}) : super(key: key); @@ -29,7 +31,7 @@ class ChatPageState extends State with WidgetsBindingObserver { String? _currentlyPlayingId; bool _isPlaying = false; final ScrollController _scrollController = ScrollController(); - bool _isFirstBuild = true; // Add this flag + bool _isFirstBuild = true; // Add this flag final FocusNode _focusNode = FocusNode(); int _previousMessageCount = 0; @@ -39,6 +41,7 @@ class ChatPageState extends State with WidgetsBindingObserver { String twoDigitSeconds = twoDigits(duration.inSeconds.remainder(60)); return "$twoDigitMinutes:$twoDigitSeconds"; } + @override void initState() { super.initState(); @@ -85,7 +88,6 @@ class ChatPageState extends State with WidgetsBindingObserver { _scrollToBottom(); } - void _scrollToBottom() { if (_scrollController.hasClients) { _scrollController.animateTo( @@ -96,13 +98,11 @@ class ChatPageState extends State with WidgetsBindingObserver { } } - @override void didChangeDependencies() { super.didChangeDependencies(); } - @override Widget build(BuildContext context) { if (Provider.of(context).conversations[widget.converser] != null) { @@ -129,7 +129,8 @@ class ChatPageState extends State with WidgetsBindingObserver { Map> groupedMessages = {}; for (var msg in messageList) { - String date = DateFormat('dd/MM/yyyy').format(DateTime.parse(msg.timestamp)); + String date = + DateFormat('dd/MM/yyyy').format(DateTime.parse(msg.timestamp)); if (groupedMessages[date] == null) { groupedMessages[date] = []; } @@ -139,6 +140,28 @@ class ChatPageState extends State with WidgetsBindingObserver { return Scaffold( appBar: AppBar( title: Text(widget.converser), + actions: [ + IconButton( + icon: const Icon(Icons.download), + onPressed: () async { + try { + final file = await generatePdf(converser: widget.converser); + if (mounted) { + _showExportOptions(context, file.path); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Failed to export chat. Please try again.'), + backgroundColor: Colors.red, + ), + ); + } + } + }, + ), + ], ), resizeToAvoidBottomInset: true, body: SafeArea( @@ -148,85 +171,99 @@ class ChatPageState extends State with WidgetsBindingObserver { child: messageList.isEmpty ? const Center(child: Text('No messages yet')) : ListView.builder( - physics: const AlwaysScrollableScrollPhysics(), - controller: _scrollController, - 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 Align( - alignment: msg.msgtype == 'sent' - ? Alignment.centerRight - : Alignment.centerLeft, - child: Bubble( - style: BubbleStyle( - nip: BubbleNip.no, - radius: const Radius.circular(18), - color: msg.msgtype == 'sent' - ? const Color(0xffd1c4e9) - : const Color(0xff80DEEA), - elevation: 2, - shadowColor: Colors.black.withOpacity(0.1), - padding: const BubbleEdges.all(12), - margin: BubbleEdges.only( - top: 10, - left: msg.msgtype == 'sent' ? 40.0 : 10, - right: msg.msgtype == 'received' ? 40.0 : 10, + physics: const AlwaysScrollableScrollPhysics(), + controller: _scrollController, + 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), + ), ), ), - child: Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Flexible( - child: msg.message.contains('file') - ? _buildFileBubble(msg) - : Text( - displayMessage, - style: const TextStyle(color: Colors.black87), + ...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 Align( + alignment: msg.msgtype == 'sent' + ? Alignment.centerRight + : Alignment.centerLeft, + child: Bubble( + style: BubbleStyle( + nip: BubbleNip.no, + radius: const Radius.circular(18), + color: msg.msgtype == 'sent' + ? const Color(0xffd1c4e9) + : const Color(0xff80DEEA), + elevation: 2, + shadowColor: + Colors.black.withOpacity(0.1), + padding: const BubbleEdges.all(12), + margin: BubbleEdges.only( + top: 10, + left: msg.msgtype == 'sent' ? 40.0 : 10, + right: msg.msgtype == 'received' + ? 40.0 + : 10, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: + CrossAxisAlignment.end, + children: [ + Flexible( + child: msg.message.contains('file') + ? _buildFileBubble(msg) + : Text( + displayMessage, + style: const TextStyle( + color: Colors.black87), + ), + ), + const SizedBox(width: 8), + Text( + dateFormatter( + timeStamp: msg.timestamp), + style: const TextStyle( + color: Colors.black54, + fontSize: 10, + ), + ), + ], + ), ), - ), - const SizedBox(width: 8), - Text( - dateFormatter(timeStamp: msg.timestamp), - style: const TextStyle( - color: Colors.black54, - fontSize: 10, - ), - ), - ], + ); + }, ), - ), + ], ); - }), - ], - ); - }, - ), + }, + ), ), Padding( - padding: const EdgeInsets.only(top: 10.0), // Add padding above MessagePanel + padding: const EdgeInsets.only( + top: 10.0), // Add padding above MessagePanel child: AnimatedContainer( duration: Duration.zero, child: MessagePanel( @@ -294,14 +331,16 @@ class ChatPageState extends State with WidgetsBindingObserver { return FutureBuilder( future: _audioPlayer.getDuration(), builder: (context, durationSnapshot) { - final totalDuration = durationSnapshot.data?.inMilliseconds ?? 1; + final totalDuration = + durationSnapshot.data?.inMilliseconds ?? 1; return LinearProgressIndicator( value: isCurrentlyPlaying && snapshot.hasData - ? snapshot.data!.inMilliseconds / totalDuration + ? snapshot.data!.inMilliseconds / + totalDuration : 0, - backgroundColor: msg.msgtype == 'sent' - ? Colors.deepPurple.withOpacity(0.1) - : Colors.cyan.withOpacity(0.1), + backgroundColor: msg.msgtype != 'sent' + ? Colors.cyan.withOpacity(0.1) + : Colors.deepPurple.withOpacity(0.1), valueColor: AlwaysStoppedAnimation( msg.msgtype == 'sent' ? Colors.deepPurple @@ -377,6 +416,7 @@ class ChatPageState extends State with WidgetsBindingObserver { ); } } + Widget _buildFileBubble(Msg msg) { dynamic data = jsonDecode(msg.message); if (data['type'] == 'voice') { @@ -408,4 +448,49 @@ class ChatPageState extends State with WidgetsBindingObserver { String dateFormatter({required String timeStamp}) { DateTime dateTime = DateTime.parse(timeStamp).toLocal(); return DateFormat.jm().format(dateTime); -}} + } +} + +void _showExportOptions(BuildContext context, String filePath) { + showModalBottomSheet( + context: context, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (context) { + return Container( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + "Export Chat", + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const Divider(), + ListTile( + leading: const Icon(Icons.visibility, color: Colors.blue), + title: const Text("Open PDF"), + onTap: () { + OpenFilex.open(filePath); + Navigator.pop(context); + }, + ), + ListTile( + leading: const Icon(Icons.share, color: Colors.green), + title: const Text("Share PDF"), + onTap: () { + Share.shareXFiles([XFile(filePath)], text: "Chat History"); + Navigator.pop(context); + }, + ), + const SizedBox(height: 10), + ], + ), + ); + }, + ); +} diff --git a/pubspec.yaml b/pubspec.yaml index 67d1ea2..dfe6f4c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -36,6 +36,9 @@ dependencies: record: ^4.4.4 audioplayers: ^6.1.0 device_info_plus: ^11.2.0 + pdf: ^3.11.3 + printing: ^5.14.2 + share_plus: ^10.1.4 dev_dependencies: