feature/add-attachment-support
into develop
2 years ago
@ -1 +1,2 @@ | |||
/mobile/.env | |||
/Backend/attachments/* |
@ -0,0 +1,50 @@ | |||
package Auth | |||
import ( | |||
"encoding/base64" | |||
"encoding/json" | |||
"net/http" | |||
"git.tovijaeschke.xyz/tovi/Envelope/Backend/Database" | |||
"git.tovijaeschke.xyz/tovi/Envelope/Backend/Models" | |||
"git.tovijaeschke.xyz/tovi/Envelope/Backend/Util" | |||
) | |||
// AddProfileImage adds a profile image | |||
func AddProfileImage(w http.ResponseWriter, r *http.Request) { | |||
var ( | |||
user Models.User | |||
attachment Models.Attachment | |||
decodedFile []byte | |||
fileName string | |||
err error | |||
) | |||
// Ignore error here, as middleware should handle auth | |||
user, _ = CheckCookieCurrentUser(w, r) | |||
err = json.NewDecoder(r.Body).Decode(&attachment) | |||
if err != nil { | |||
http.Error(w, "Error", http.StatusInternalServerError) | |||
return | |||
} | |||
if attachment.Data == "" { | |||
http.Error(w, "Error", http.StatusInternalServerError) | |||
return | |||
} | |||
decodedFile, err = base64.StdEncoding.DecodeString(attachment.Data) | |||
fileName, err = Util.WriteFile(decodedFile) | |||
attachment.FilePath = fileName | |||
user.Attachment = attachment | |||
err = Database.UpdateUser(user.ID.String(), &user) | |||
if err != nil { | |||
http.Error(w, "Error", http.StatusInternalServerError) | |||
return | |||
} | |||
w.WriteHeader(http.StatusNoContent) | |||
} |
@ -0,0 +1,64 @@ | |||
package Messages | |||
import ( | |||
"encoding/base64" | |||
"encoding/json" | |||
"net/http" | |||
"git.tovijaeschke.xyz/tovi/Envelope/Backend/Database" | |||
"git.tovijaeschke.xyz/tovi/Envelope/Backend/Models" | |||
"git.tovijaeschke.xyz/tovi/Envelope/Backend/Util" | |||
"github.com/gorilla/mux" | |||
) | |||
// AddConversationImage adds an image for a conversation icon | |||
func AddConversationImage(w http.ResponseWriter, r *http.Request) { | |||
var ( | |||
attachment Models.Attachment | |||
conversationDetail Models.ConversationDetail | |||
urlVars map[string]string | |||
detailID string | |||
decodedFile []byte | |||
fileName string | |||
ok bool | |||
err error | |||
) | |||
urlVars = mux.Vars(r) | |||
detailID, ok = urlVars["detailID"] | |||
if !ok { | |||
http.Error(w, "Not Found", http.StatusNotFound) | |||
return | |||
} | |||
conversationDetail, err = Database.GetConversationDetailByID(detailID) | |||
if err != nil { | |||
http.Error(w, "Not Found", http.StatusNotFound) | |||
return | |||
} | |||
err = json.NewDecoder(r.Body).Decode(&attachment) | |||
if err != nil { | |||
http.Error(w, "Error", http.StatusInternalServerError) | |||
return | |||
} | |||
if attachment.Data == "" { | |||
http.Error(w, "Error", http.StatusInternalServerError) | |||
return | |||
} | |||
decodedFile, err = base64.StdEncoding.DecodeString(attachment.Data) | |||
fileName, err = Util.WriteFile(decodedFile) | |||
attachment.FilePath = fileName | |||
conversationDetail.Attachment = attachment | |||
err = Database.UpdateConversationDetail(&conversationDetail) | |||
if err != nil { | |||
http.Error(w, "Error", http.StatusInternalServerError) | |||
return | |||
} | |||
w.WriteHeader(http.StatusNoContent) | |||
} |
@ -1,41 +1,55 @@ | |||
package Messages | |||
import ( | |||
"encoding/base64" | |||
"encoding/json" | |||
"net/http" | |||
"git.tovijaeschke.xyz/tovi/Envelope/Backend/Database" | |||
"git.tovijaeschke.xyz/tovi/Envelope/Backend/Models" | |||
"git.tovijaeschke.xyz/tovi/Envelope/Backend/Util" | |||
) | |||
type RawMessageData struct { | |||
type rawMessageData struct { | |||
MessageData Models.MessageData `json:"message_data"` | |||
Messages []Models.Message `json:"message"` | |||
} | |||
// CreateMessage sends a message | |||
func CreateMessage(w http.ResponseWriter, r *http.Request) { | |||
var ( | |||
rawMessageData RawMessageData | |||
messagesData []rawMessageData | |||
messageData rawMessageData | |||
decodedFile []byte | |||
fileName string | |||
err error | |||
) | |||
err = json.NewDecoder(r.Body).Decode(&rawMessageData) | |||
err = json.NewDecoder(r.Body).Decode(&messagesData) | |||
if err != nil { | |||
http.Error(w, "Error", http.StatusInternalServerError) | |||
return | |||
} | |||
err = Database.CreateMessageData(&rawMessageData.MessageData) | |||
for _, messageData = range messagesData { | |||
if messageData.MessageData.Data == "" { | |||
decodedFile, err = base64.StdEncoding.DecodeString(messageData.MessageData.Attachment.Data) | |||
fileName, err = Util.WriteFile(decodedFile) | |||
messageData.MessageData.Attachment.FilePath = fileName | |||
} | |||
err = Database.CreateMessageData(&messageData.MessageData) | |||
if err != nil { | |||
http.Error(w, "Error", http.StatusInternalServerError) | |||
return | |||
} | |||
err = Database.CreateMessages(&rawMessageData.Messages) | |||
err = Database.CreateMessages(&messageData.Messages) | |||
if err != nil { | |||
http.Error(w, "Error", http.StatusInternalServerError) | |||
return | |||
} | |||
} | |||
w.WriteHeader(http.StatusOK) | |||
} |
@ -0,0 +1,42 @@ | |||
package Database | |||
import ( | |||
"git.tovijaeschke.xyz/tovi/Envelope/Backend/Models" | |||
"gorm.io/gorm" | |||
"gorm.io/gorm/clause" | |||
) | |||
// GetAttachmentByID gets the attachment record by the id | |||
func GetAttachmentByID(id string) (Models.MessageData, error) { | |||
var ( | |||
messageData Models.MessageData | |||
err error | |||
) | |||
err = DB.Preload(clause.Associations). | |||
First(&messageData, "id = ?", id). | |||
Error | |||
return messageData, err | |||
} | |||
// CreateAttachment creates the attachment record | |||
func CreateAttachment(messageData *Models.MessageData) error { | |||
var ( | |||
err error | |||
) | |||
err = DB.Session(&gorm.Session{FullSaveAssociations: true}). | |||
Create(messageData). | |||
Error | |||
return err | |||
} | |||
// DeleteAttachment deletes the attachment record | |||
func DeleteAttachment(messageData *Models.MessageData) error { | |||
return DB.Session(&gorm.Session{FullSaveAssociations: true}). | |||
Delete(messageData). | |||
Error | |||
} |
@ -0,0 +1,11 @@ | |||
package Models | |||
// Attachment holds the attachment data | |||
type Attachment struct { | |||
Base | |||
FilePath string `gorm:"not null" json:"-"` | |||
Mimetype string `gorm:"not null" json:"mimetype"` | |||
Extension string `gorm:"not null" json:"extension"` | |||
Data string `gorm:"-" json:"data"` | |||
ImageLink string `gorm:"-" json:"image_link"` | |||
} |
@ -0,0 +1,46 @@ | |||
package Util | |||
import ( | |||
"fmt" | |||
"os" | |||
) | |||
// WriteFile to disk | |||
func WriteFile(contents []byte) (string, error) { | |||
var ( | |||
fileName string | |||
filePath string | |||
cwd string | |||
f *os.File | |||
err error | |||
) | |||
cwd, err = os.Getwd() | |||
if err != nil { | |||
return fileName, err | |||
} | |||
fileName = RandomString(32) | |||
filePath = fmt.Sprintf( | |||
"%s/attachments/%s", | |||
cwd, | |||
fileName, | |||
) | |||
f, err = os.Create(filePath) | |||
if err != nil { | |||
return fileName, err | |||
} | |||
defer f.Close() | |||
_, err = f.Write(contents) | |||
if err != nil { | |||
return fileName, err | |||
} | |||
return fileName, nil | |||
} |
@ -0,0 +1,106 @@ | |||
import 'package:flutter/material.dart'; | |||
import 'package:image_picker/image_picker.dart'; | |||
class FilePicker extends StatelessWidget { | |||
FilePicker({ | |||
Key? key, | |||
this.cameraHandle, | |||
this.galleryHandleSingle, | |||
this.galleryHandleMultiple, | |||
this.fileHandle, | |||
}) : super(key: key); | |||
final Function(XFile image)? cameraHandle; | |||
final Function(XFile image)? galleryHandleSingle; | |||
final Function(List<XFile> images)? galleryHandleMultiple; | |||
// TODO: Implement. Perhaps after first release? | |||
final Function()? fileHandle; | |||
final ImagePicker _picker = ImagePicker(); | |||
@override | |||
Widget build(BuildContext context) { | |||
return Padding( | |||
padding: const EdgeInsets.only(top: 10, bottom: 10, left: 5, right: 5), | |||
child: Row( | |||
mainAxisAlignment: MainAxisAlignment.center, | |||
children: [ | |||
_filePickerSelection( | |||
hasHandle: cameraHandle != null, | |||
icon: Icons.camera_alt, | |||
onTap: () async { | |||
final XFile? image = await _picker.pickImage(source: ImageSource.camera); | |||
if (image == null) { | |||
return; | |||
} | |||
cameraHandle!(image); | |||
}, | |||
context: context, | |||
), | |||
_filePickerSelection( | |||
hasHandle: galleryHandleSingle != null, | |||
icon: Icons.image, | |||
onTap: () async { | |||
final XFile? image = await _picker.pickImage(source: ImageSource.gallery); | |||
if (image == null) { | |||
return; | |||
} | |||
galleryHandleSingle!(image); | |||
}, | |||
context: context, | |||
), | |||
_filePickerSelection( | |||
hasHandle: galleryHandleMultiple != null, | |||
icon: Icons.image, | |||
onTap: () async { | |||
final List<XFile>? images = await _picker.pickMultiImage(); | |||
if (images == null) { | |||
return; | |||
} | |||
galleryHandleMultiple!(images); | |||
}, | |||
context: context, | |||
), | |||
_filePickerSelection( | |||
hasHandle: fileHandle != null, | |||
icon: Icons.file_present_sharp, | |||
onTap: () { | |||
}, | |||
context: context, | |||
), | |||
], | |||
) | |||
); | |||
} | |||
Widget _filePickerSelection({ | |||
required bool hasHandle, | |||
required IconData icon, | |||
required Function() onTap, | |||
required BuildContext context | |||
}) { | |||
if (!hasHandle) { | |||
return const SizedBox.shrink(); | |||
} | |||
return Padding( | |||
padding: const EdgeInsets.only(left: 5, right: 5), | |||
child: GestureDetector( | |||
onTap: onTap, | |||
child: Container( | |||
height: 75, | |||
width: 75, | |||
decoration: BoxDecoration( | |||
color: Theme.of(context).primaryColor, | |||
borderRadius: BorderRadius.circular(25), | |||
), | |||
child: Icon( | |||
icon, | |||
size: 40, | |||
), | |||
), | |||
), | |||
); | |||
} | |||
} | |||
@ -0,0 +1,35 @@ | |||
import 'package:Envelope/components/custom_title_bar.dart'; | |||
import 'package:Envelope/models/image_message.dart'; | |||
import 'package:flutter/material.dart'; | |||
class ViewImage extends StatelessWidget { | |||
const ViewImage({ | |||
Key? key, | |||
required this.message, | |||
}) : super(key: key); | |||
final ImageMessage message; | |||
@override | |||
Widget build(BuildContext context) { | |||
return Scaffold( | |||
backgroundColor: Colors.black, | |||
appBar: const CustomTitleBar( | |||
title: Text(''), | |||
showBack: true, | |||
backgroundColor: Colors.black, | |||
), | |||
body: Center( | |||
child: InteractiveViewer( | |||
panEnabled: false, | |||
minScale: 1, | |||
maxScale: 4, | |||
child: Image.file( | |||
message.file, | |||
), | |||
) | |||
), | |||
); | |||
} | |||
} | |||
@ -0,0 +1,13 @@ | |||
class UpdateDataException implements Exception { | |||
final String _message; | |||
UpdateDataException([ | |||
this._message = 'An error occured while updating data.', | |||
]); | |||
@override | |||
String toString() { | |||
return _message; | |||
} | |||
} |
@ -0,0 +1,145 @@ | |||
import 'dart:convert'; | |||
import 'dart:io'; | |||
import 'dart:typed_data'; | |||
import 'package:Envelope/models/my_profile.dart'; | |||
import 'package:Envelope/utils/storage/get_file.dart'; | |||
import 'package:Envelope/utils/storage/write_file.dart'; | |||
import 'package:mime/mime.dart'; | |||
import 'package:pointycastle/pointycastle.dart'; | |||
import 'package:uuid/uuid.dart'; | |||
import '/models/conversations.dart'; | |||
import '/models/messages.dart'; | |||
import '/utils/encryption/aes_helper.dart'; | |||
import '/utils/encryption/crypto_utils.dart'; | |||
import '/utils/strings.dart'; | |||
class ImageMessage extends Message { | |||
File file; | |||
ImageMessage({ | |||
id, | |||
symmetricKey, | |||
userSymmetricKey, | |||
senderId, | |||
senderUsername, | |||
associationKey, | |||
createdAt, | |||
failedToSend, | |||
required this.file, | |||
}) : super( | |||
id: id, | |||
symmetricKey: symmetricKey, | |||
userSymmetricKey: userSymmetricKey, | |||
senderId: senderId, | |||
senderUsername: senderUsername, | |||
associationKey: associationKey, | |||
createdAt: createdAt, | |||
failedToSend: failedToSend, | |||
); | |||
static Future<ImageMessage> fromJson(Map<String, dynamic> json, RSAPrivateKey privKey) async { | |||
var userSymmetricKey = CryptoUtils.rsaDecrypt( | |||
base64.decode(json['symmetric_key']), | |||
privKey, | |||
); | |||
var symmetricKey = AesHelper.aesDecrypt( | |||
userSymmetricKey, | |||
base64.decode(json['message_data']['symmetric_key']), | |||
); | |||
var senderId = AesHelper.aesDecrypt( | |||
base64.decode(symmetricKey), | |||
base64.decode(json['message_data']['sender_id']), | |||
); | |||
File file = await getFile( | |||
'$defaultServerUrl/files/${json['message_data']['attachment']['image_link']}', | |||
'${json['id']}', | |||
symmetricKey, | |||
); | |||
return ImageMessage( | |||
id: json['id'], | |||
symmetricKey: symmetricKey, | |||
userSymmetricKey: base64.encode(userSymmetricKey), | |||
senderId: senderId, | |||
senderUsername: 'Unknown', | |||
associationKey: json['association_key'], | |||
createdAt: json['created_at'], | |||
failedToSend: false, | |||
file: file, | |||
); | |||
} | |||
@override | |||
Map<String, dynamic> toMap() { | |||
return { | |||
'id': id, | |||
'symmetric_key': symmetricKey, | |||
'user_symmetric_key': userSymmetricKey, | |||
'file': file.path, | |||
'sender_id': senderId, | |||
'sender_username': senderUsername, | |||
'association_key': associationKey, | |||
'created_at': createdAt, | |||
'failed_to_send': failedToSend ? 1 : 0, | |||
}; | |||
} | |||
Future<Map<String, dynamic>> payloadJson(Conversation conversation) async { | |||
final String messageDataId = (const Uuid()).v4(); | |||
final symmetricKey = AesHelper.deriveKey(generateRandomString(32)); | |||
final userSymmetricKey = AesHelper.deriveKey(generateRandomString(32)); | |||
List<Map<String, String>> messages = await super.payloadJsonBase( | |||
symmetricKey, | |||
userSymmetricKey, | |||
conversation, | |||
id, | |||
messageDataId, | |||
); | |||
Map<String, dynamic> messageData = { | |||
'id': messageDataId, | |||
'sender_id': AesHelper.aesEncrypt(symmetricKey, Uint8List.fromList(senderId.codeUnits)), | |||
'symmetric_key': AesHelper.aesEncrypt( | |||
userSymmetricKey, | |||
Uint8List.fromList(base64.encode(symmetricKey).codeUnits), | |||
), | |||
'attachment': { | |||
'data': AesHelper.aesEncrypt(base64.encode(symmetricKey), Uint8List.fromList(file.readAsBytesSync())), | |||
'mimetype': lookupMimeType(file.path), | |||
'extension': getExtension(file.path), | |||
} | |||
}; | |||
return <String, dynamic>{ | |||
'message_data': messageData, | |||
'message': messages, | |||
}; | |||
} | |||
@override | |||
String getContent() { | |||
return 'Image'; | |||
} | |||
@override | |||
String toString() { | |||
return ''' | |||
id: $id | |||
file: ${file.path}, | |||
senderId: $senderId | |||
senderUsername: $senderUsername | |||
associationKey: $associationKey | |||
createdAt: $createdAt | |||
'''; | |||
} | |||
} |
@ -0,0 +1,135 @@ | |||
import 'dart:convert'; | |||
import 'dart:typed_data'; | |||
import 'package:pointycastle/pointycastle.dart'; | |||
import 'package:uuid/uuid.dart'; | |||
import '/models/conversations.dart'; | |||
import '/models/messages.dart'; | |||
import '/utils/encryption/aes_helper.dart'; | |||
import '/utils/encryption/crypto_utils.dart'; | |||
import '/utils/strings.dart'; | |||
class TextMessage extends Message { | |||
String text; | |||
TextMessage({ | |||
id, | |||
symmetricKey, | |||
userSymmetricKey, | |||
senderId, | |||
senderUsername, | |||
associationKey, | |||
createdAt, | |||
failedToSend, | |||
required this.text, | |||
}) : super( | |||
id: id, | |||
symmetricKey: symmetricKey, | |||
userSymmetricKey: userSymmetricKey, | |||
senderId: senderId, | |||
senderUsername: senderUsername, | |||
associationKey: associationKey, | |||
createdAt: createdAt, | |||
failedToSend: failedToSend, | |||
); | |||
factory TextMessage.fromJson(Map<String, dynamic> json, RSAPrivateKey privKey) { | |||
var userSymmetricKey = CryptoUtils.rsaDecrypt( | |||
base64.decode(json['symmetric_key']), | |||
privKey, | |||
); | |||
var symmetricKey = AesHelper.aesDecrypt( | |||
userSymmetricKey, | |||
base64.decode(json['message_data']['symmetric_key']), | |||
); | |||
var senderId = AesHelper.aesDecrypt( | |||
base64.decode(symmetricKey), | |||
base64.decode(json['message_data']['sender_id']), | |||
); | |||
var data = AesHelper.aesDecrypt( | |||
base64.decode(symmetricKey), | |||
base64.decode(json['message_data']['data']), | |||
); | |||
return TextMessage( | |||
id: json['id'], | |||
symmetricKey: symmetricKey, | |||
userSymmetricKey: base64.encode(userSymmetricKey), | |||
senderId: senderId, | |||
senderUsername: 'Unknown', | |||
associationKey: json['association_key'], | |||
createdAt: json['created_at'], | |||
failedToSend: false, | |||
text: data, | |||
); | |||
} | |||
@override | |||
Map<String, dynamic> toMap() { | |||
return { | |||
'id': id, | |||
'symmetric_key': symmetricKey, | |||
'user_symmetric_key': userSymmetricKey, | |||
'data': text, | |||
'sender_id': senderId, | |||
'sender_username': senderUsername, | |||
'association_key': associationKey, | |||
'created_at': createdAt, | |||
'failed_to_send': failedToSend ? 1 : 0, | |||
}; | |||
} | |||
Future<Map<String, dynamic>> payloadJson(Conversation conversation) async { | |||
final String messageDataId = (const Uuid()).v4(); | |||
final symmetricKey = AesHelper.deriveKey(generateRandomString(32)); | |||
final userSymmetricKey = AesHelper.deriveKey(generateRandomString(32)); | |||
List<Map<String, String>> messages = await super.payloadJsonBase( | |||
symmetricKey, | |||
userSymmetricKey, | |||
conversation, | |||
id, | |||
messageDataId, | |||
); | |||
Map<String, String> messageData = { | |||
'id': id, | |||
'data': AesHelper.aesEncrypt(symmetricKey, Uint8List.fromList(text.codeUnits)), | |||
'sender_id': AesHelper.aesEncrypt(symmetricKey, Uint8List.fromList(senderId.codeUnits)), | |||
'symmetric_key': AesHelper.aesEncrypt( | |||
userSymmetricKey, | |||
Uint8List.fromList(base64.encode(symmetricKey).codeUnits), | |||
), | |||
}; | |||
return <String, dynamic>{ | |||
'message_data': messageData, | |||
'message': messages, | |||
}; | |||
} | |||
@override | |||
String getContent() { | |||
return text; | |||
} | |||
@override | |||
String toString() { | |||
return ''' | |||
id: $id | |||
data: $text, | |||
senderId: $senderId | |||
senderUsername: $senderUsername | |||
associationKey: $associationKey | |||
createdAt: $createdAt | |||
'''; | |||
} | |||
} |
@ -0,0 +1,32 @@ | |||
import 'dart:io'; | |||
import 'package:http/http.dart' as http; | |||
import '/utils/encryption/aes_helper.dart'; | |||
import '/utils/storage/session_cookie.dart'; | |||
import '/utils/storage/write_file.dart'; | |||
Future<File> getFile(String link, String imageName, dynamic symmetricKey) async { | |||
var resp = await http.get( | |||
Uri.parse(link), | |||
headers: { | |||
'cookie': await getSessionCookie(), | |||
} | |||
); | |||
if (resp.statusCode != 200) { | |||
throw Exception('Could not get attachment file'); | |||
} | |||
var data = AesHelper.aesDecryptBytes( | |||
symmetricKey, | |||
resp.bodyBytes, | |||
); | |||
File file = await writeImage( | |||
imageName, | |||
data, | |||
); | |||
return file; | |||
} |
@ -0,0 +1,26 @@ | |||
import 'dart:io'; | |||
import 'dart:typed_data'; | |||
import 'package:path_provider/path_provider.dart'; | |||
Future<String> get _localPath async { | |||
final directory = await getApplicationDocumentsDirectory(); | |||
return directory.path; | |||
} | |||
Future<File> _localFile(String fileName) async { | |||
final path = await _localPath; | |||
return File('$path/$fileName'); | |||
} | |||
Future<File> writeImage(String fileName, Uint8List data) async { | |||
final file = await _localFile(fileName); | |||
// Write the file | |||
return file.writeAsBytes(data); | |||
} | |||
String getExtension(String fileName) { | |||
return fileName.split('.').last; | |||
} |
@ -0,0 +1,241 @@ | |||
import 'package:Envelope/components/view_image.dart'; | |||
import 'package:Envelope/models/image_message.dart'; | |||
import 'package:Envelope/models/my_profile.dart'; | |||
import 'package:Envelope/utils/time.dart'; | |||
import 'package:flutter/material.dart'; | |||
import '/models/messages.dart'; | |||
@immutable | |||
class ConversationMessage extends StatefulWidget { | |||
const ConversationMessage({ | |||
Key? key, | |||
required this.message, | |||
required this.profile, | |||
required this.index, | |||
}) : super(key: key); | |||
final Message message; | |||
final MyProfile profile; | |||
final int index; | |||
@override | |||
_ConversationMessageState createState() => _ConversationMessageState(); | |||
} | |||
class _ConversationMessageState extends State<ConversationMessage> { | |||
List<PopupMenuEntry<String>> menuItems = []; | |||
Offset? _tapPosition; | |||
bool showDownloadButton = false; | |||
bool showDeleteButton = false; | |||
@override | |||
void initState() { | |||
super.initState(); | |||
showDownloadButton = widget.message.runtimeType == ImageMessage; | |||
showDeleteButton = widget.message.senderId == widget.profile.id; | |||
if (showDownloadButton) { | |||
menuItems.add(PopupMenuItem( | |||
value: 'download', | |||
child: Row( | |||
children: const [ | |||
Icon(Icons.download), | |||
SizedBox( | |||
width: 10, | |||
), | |||
Text('Download') | |||
], | |||
), | |||
)); | |||
} | |||
if (showDeleteButton) { | |||
menuItems.add(PopupMenuItem( | |||
value: 'delete', | |||
child: Row( | |||
children: const [ | |||
Icon(Icons.delete), | |||
SizedBox( | |||
width: 10, | |||
), | |||
Text('Delete') | |||
], | |||
), | |||
)); | |||
} | |||
setState(() {}); | |||
} | |||
@override | |||
Widget build(BuildContext context) { | |||
return Container( | |||
padding: const EdgeInsets.only(left: 14,right: 14,top: 0,bottom: 0), | |||
child: Align( | |||
alignment: ( | |||
widget.message.senderId == widget.profile.id ? | |||
Alignment.topRight : | |||
Alignment.topLeft | |||
), | |||
child: Column( | |||
crossAxisAlignment: widget.message.senderId == widget.profile.id ? | |||
CrossAxisAlignment.end : | |||
CrossAxisAlignment.start, | |||
children: <Widget>[ | |||
messageContent(context), | |||
const SizedBox(height: 1.5), | |||
Row( | |||
mainAxisAlignment: widget.message.senderId == widget.profile.id ? | |||
MainAxisAlignment.end : | |||
MainAxisAlignment.start, | |||
children: <Widget>[ | |||
const SizedBox(width: 10), | |||
usernameOrFailedToSend(), | |||
], | |||
), | |||
const SizedBox(height: 1.5), | |||
Row( | |||
mainAxisAlignment: widget.message.senderId == widget.profile.id ? | |||
MainAxisAlignment.end : | |||
MainAxisAlignment.start, | |||
children: <Widget>[ | |||
const SizedBox(width: 10), | |||
Text( | |||
convertToAgo(widget.message.createdAt), | |||
textAlign: widget.message.senderId == widget.profile.id ? | |||
TextAlign.left : | |||
TextAlign.right, | |||
style: TextStyle( | |||
fontSize: 12, | |||
color: Colors.grey[500], | |||
), | |||
), | |||
], | |||
), | |||
widget.index != 0 ? | |||
const SizedBox(height: 20) : | |||
const SizedBox.shrink(), | |||
], | |||
) | |||
), | |||
); | |||
} | |||
void _showCustomMenu() { | |||
final Size overlay = MediaQuery.of(context).size; | |||
int addVerticalOffset = 75 * menuItems.length; | |||
// TODO: Implement download & delete methods | |||
showMenu( | |||
context: context, | |||
items: menuItems, | |||
position: RelativeRect.fromRect( | |||
Offset(_tapPosition!.dx, (_tapPosition!.dy - addVerticalOffset)) & const Size(40, 40), | |||
Offset.zero & overlay | |||
) | |||
) | |||
.then<void>((String? delta) async { | |||
if (delta == null) { | |||
return; | |||
} | |||
}); | |||
} | |||
void _storePosition(TapDownDetails details) { | |||
_tapPosition = details.globalPosition; | |||
} | |||
Widget messageContent(BuildContext context) { | |||
if (widget.message.runtimeType == ImageMessage) { | |||
return GestureDetector( | |||
onTap: () { | |||
Navigator.push(context, MaterialPageRoute(builder: (context) { | |||
return ViewImage( | |||
message: (widget.message as ImageMessage) | |||
); | |||
})); | |||
}, | |||
onLongPress: _showCustomMenu, | |||
onTapDown: _storePosition, | |||
child: ConstrainedBox( | |||
constraints: const BoxConstraints(maxHeight: 350, maxWidth: 250), | |||
child: ClipRRect( | |||
borderRadius: BorderRadius.circular(20), child: Image.file( | |||
(widget.message as ImageMessage).file, | |||
fit: BoxFit.fill, | |||
), | |||
), | |||
), | |||
); | |||
} | |||
return GestureDetector( | |||
onLongPress: _showCustomMenu, | |||
onTapDown: _storePosition, | |||
child: Container( | |||
decoration: BoxDecoration( | |||
borderRadius: BorderRadius.circular(20), | |||
color: ( | |||
widget.message.senderId == widget.profile.id ? | |||
Theme.of(context).colorScheme.primary : | |||
Theme.of(context).colorScheme.tertiary | |||
), | |||
), | |||
padding: const EdgeInsets.all(12), | |||
child: Text( | |||
widget.message.getContent(), | |||
style: TextStyle( | |||
fontSize: 15, | |||
color: widget.message.senderId == widget.profile.id ? | |||
Theme.of(context).colorScheme.onPrimary : | |||
Theme.of(context).colorScheme.onTertiary, | |||
), | |||
), | |||
), | |||
); | |||
} | |||
Widget usernameOrFailedToSend() { | |||
if (widget.message.senderId != widget.profile.id) { | |||
return Text( | |||
widget.message.senderUsername, | |||
style: TextStyle( | |||
fontSize: 12, | |||
color: Colors.grey[300], | |||
), | |||
); | |||
} | |||
if (widget.message.failedToSend) { | |||
return Row( | |||
mainAxisAlignment: MainAxisAlignment.end, | |||
children: const <Widget>[ | |||
Icon( | |||
Icons.warning_rounded, | |||
color: Colors.red, | |||
size: 20, | |||
), | |||
Text( | |||
'Failed to send', | |||
style: TextStyle(color: Colors.red, fontSize: 12), | |||
textAlign: TextAlign.right, | |||
), | |||
], | |||
); | |||
} | |||
return const SizedBox.shrink(); | |||
} | |||
} |