feature/add-attachment-support into develop 3 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,40 +1,54 @@ | |||
| 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 | |||
| err error | |||
| 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) | |||
| if err != nil { | |||
| http.Error(w, "Error", http.StatusInternalServerError) | |||
| return | |||
| } | |||
| err = Database.CreateMessages(&rawMessageData.Messages) | |||
| if err != nil { | |||
| http.Error(w, "Error", http.StatusInternalServerError) | |||
| return | |||
| 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(&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(); | |||
| } | |||
| } | |||