Reviewed-on: #3pull/4/head
@ -1 +1,2 @@ | |||||
/mobile/.env | /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 | package Messages | ||||
import ( | import ( | ||||
"encoding/base64" | |||||
"encoding/json" | "encoding/json" | ||||
"net/http" | "net/http" | ||||
"git.tovijaeschke.xyz/tovi/Envelope/Backend/Database" | "git.tovijaeschke.xyz/tovi/Envelope/Backend/Database" | ||||
"git.tovijaeschke.xyz/tovi/Envelope/Backend/Models" | "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"` | MessageData Models.MessageData `json:"message_data"` | ||||
Messages []Models.Message `json:"message"` | Messages []Models.Message `json:"message"` | ||||
} | } | ||||
// CreateMessage sends a message | |||||
func CreateMessage(w http.ResponseWriter, r *http.Request) { | func CreateMessage(w http.ResponseWriter, r *http.Request) { | ||||
var ( | 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 { | if err != nil { | ||||
http.Error(w, "Error", http.StatusInternalServerError) | http.Error(w, "Error", http.StatusInternalServerError) | ||||
return | 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) | 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(); | |||||
} | |||||
} |