| @ -0,0 +1,39 @@ | |||
| package Database | |||
| import ( | |||
| "git.tovijaeschke.xyz/tovi/Envelope/Backend/Models" | |||
| "gorm.io/gorm" | |||
| "gorm.io/gorm/clause" | |||
| ) | |||
| func GetMessageDataById(id string) (Models.MessageData, error) { | |||
| var ( | |||
| messageData Models.MessageData | |||
| err error | |||
| ) | |||
| err = DB.Preload(clause.Associations). | |||
| First(&messageData, "id = ?", id). | |||
| Error | |||
| return messageData, err | |||
| } | |||
| func CreateMessageData(messageData *Models.MessageData) error { | |||
| var ( | |||
| err error | |||
| ) | |||
| err = DB.Session(&gorm.Session{FullSaveAssociations: true}). | |||
| Create(messageData). | |||
| Error | |||
| return err | |||
| } | |||
| func DeleteMessageData(messageData *Models.MessageData) error { | |||
| return DB.Session(&gorm.Session{FullSaveAssociations: true}). | |||
| Delete(messageData). | |||
| Error | |||
| } | |||
| @ -0,0 +1,104 @@ | |||
| import '/utils/storage/database.dart'; | |||
| import '/models/conversations.dart'; | |||
| class ConversationUser{ | |||
| String id; | |||
| String conversationId; | |||
| String username; | |||
| String associationKey; | |||
| String admin; | |||
| ConversationUser({ | |||
| required this.id, | |||
| required this.conversationId, | |||
| required this.username, | |||
| required this.associationKey, | |||
| required this.admin, | |||
| }); | |||
| factory ConversationUser.fromJson(Map<String, dynamic> json, String conversationId) { | |||
| return ConversationUser( | |||
| id: json['id'], | |||
| conversationId: conversationId, | |||
| username: json['username'], | |||
| associationKey: json['association_key'], | |||
| admin: json['admin'], | |||
| ); | |||
| } | |||
| Map<String, dynamic> toMap() { | |||
| return { | |||
| 'id': id, | |||
| 'conversation_id': conversationId, | |||
| 'username': username, | |||
| 'association_key': associationKey, | |||
| 'admin': admin, | |||
| }; | |||
| } | |||
| } | |||
| // A method that retrieves all the dogs from the dogs table. | |||
| Future<List<ConversationUser>> getConversationUsers(Conversation conversation) async { | |||
| final db = await getDatabaseConnection(); | |||
| final List<Map<String, dynamic>> maps = await db.query( | |||
| 'conversation_users', | |||
| where: 'conversation_id = ?', | |||
| whereArgs: [conversation.id], | |||
| ); | |||
| return List.generate(maps.length, (i) { | |||
| return ConversationUser( | |||
| id: maps[i]['id'], | |||
| conversationId: maps[i]['conversation_id'], | |||
| username: maps[i]['username'], | |||
| associationKey: maps[i]['association_key'], | |||
| admin: maps[i]['admin'], | |||
| ); | |||
| }); | |||
| } | |||
| Future<ConversationUser> getConversationUserById(Conversation conversation, String id) async { | |||
| final db = await getDatabaseConnection(); | |||
| final List<Map<String, dynamic>> maps = await db.query( | |||
| 'conversation_users', | |||
| where: 'conversation_id = ? AND id = ?', | |||
| whereArgs: [conversation.id, id], | |||
| ); | |||
| if (maps.length != 1) { | |||
| throw ArgumentError('Invalid conversation_id or id'); | |||
| } | |||
| return ConversationUser( | |||
| id: maps[0]['id'], | |||
| conversationId: maps[0]['conversation_id'], | |||
| username: maps[0]['username'], | |||
| associationKey: maps[0]['association_key'], | |||
| admin: maps[0]['admin'], | |||
| ); | |||
| } | |||
| Future<ConversationUser> getConversationUserByUsername(Conversation conversation, String username) async { | |||
| final db = await getDatabaseConnection(); | |||
| final List<Map<String, dynamic>> maps = await db.query( | |||
| 'conversation_users', | |||
| where: 'conversation_id = ? AND username = ?', | |||
| whereArgs: [conversation.id, username], | |||
| ); | |||
| if (maps.length != 1) { | |||
| throw ArgumentError('Invalid conversation_id or username'); | |||
| } | |||
| return ConversationUser( | |||
| id: maps[0]['id'], | |||
| conversationId: maps[0]['conversation_id'], | |||
| username: maps[0]['username'], | |||
| associationKey: maps[0]['association_key'], | |||
| admin: maps[0]['admin'], | |||
| ); | |||
| } | |||
| @ -1,76 +1,112 @@ | |||
| import 'dart:convert'; | |||
| import 'package:Envelope/models/messages.dart'; | |||
| import 'package:uuid/uuid.dart'; | |||
| import 'package:Envelope/models/conversation_users.dart'; | |||
| import 'package:intl/intl.dart'; | |||
| import 'package:http/http.dart' as http; | |||
| import 'package:flutter_dotenv/flutter_dotenv.dart'; | |||
| import 'package:pointycastle/export.dart'; | |||
| import 'package:sqflite/sqflite.dart'; | |||
| import 'package:Envelope/models/conversations.dart'; | |||
| import 'package:shared_preferences/shared_preferences.dart'; | |||
| import '/utils/storage/session_cookie.dart'; | |||
| import '/utils/storage/encryption_keys.dart'; | |||
| import '/utils/storage/database.dart'; | |||
| import '/models/conversations.dart'; | |||
| // TODO: Move this to table | |||
| Map<String, Map<String, String>> _mapUsers(String users) { | |||
| List<dynamic> usersJson = jsonDecode(users); | |||
| Map<String, Map<String, String>> mapped = {}; | |||
| for (var i = 0; i < usersJson.length; i++) { | |||
| mapped[usersJson[i]['id']] = { | |||
| 'username': usersJson[i]['username'], | |||
| 'admin': usersJson[i]['admin'], | |||
| }; | |||
| } | |||
| return mapped; | |||
| } | |||
| import '/models/messages.dart'; | |||
| Future<void> updateMessageThread(Conversation conversation, {RSAPrivateKey? privKey}) async { | |||
| privKey ??= await getPrivateKey(); | |||
| var resp = await http.get( | |||
| Uri.parse('${dotenv.env["SERVER_URL"]}api/v1/auth/messages/${conversation.messageThreadKey}'), | |||
| headers: { | |||
| 'cookie': await getSessionCookie(), | |||
| } | |||
| privKey ??= await getPrivateKey(); | |||
| final preferences = await SharedPreferences.getInstance(); | |||
| String username = preferences.getString('username')!; | |||
| ConversationUser currentUser = await getConversationUserByUsername(conversation, username); | |||
| var resp = await http.get( | |||
| Uri.parse('${dotenv.env["SERVER_URL"]}api/v1/auth/messages/${currentUser.associationKey}'), | |||
| headers: { | |||
| 'cookie': await getSessionCookie(), | |||
| } | |||
| ); | |||
| if (resp.statusCode != 200) { | |||
| throw Exception(resp.body); | |||
| } | |||
| List<dynamic> messageThreadJson = jsonDecode(resp.body); | |||
| final db = await getDatabaseConnection(); | |||
| for (var i = 0; i < messageThreadJson.length; i++) { | |||
| Message message = Message.fromJson( | |||
| messageThreadJson[i] as Map<String, dynamic>, | |||
| privKey, | |||
| ); | |||
| if (resp.statusCode != 200) { | |||
| throw Exception(resp.body); | |||
| } | |||
| var mapped = _mapUsers(conversation.users!); | |||
| ConversationUser messageUser = await getConversationUserById(conversation, message.senderId); | |||
| message.senderUsername = messageUser.username; | |||
| List<dynamic> messageThreadJson = jsonDecode(resp.body); | |||
| final db = await getDatabaseConnection(); | |||
| for (var i = 0; i < messageThreadJson.length; i++) { | |||
| Message message = Message.fromJson( | |||
| messageThreadJson[i] as Map<String, dynamic>, | |||
| privKey, | |||
| ); | |||
| // TODO: Fix this | |||
| message.senderUsername = mapped[message.senderId]!['username']!; | |||
| await db.insert( | |||
| 'messages', | |||
| message.toMap(), | |||
| conflictAlgorithm: ConflictAlgorithm.replace, | |||
| ); | |||
| } | |||
| await db.insert( | |||
| 'messages', | |||
| message.toMap(), | |||
| conflictAlgorithm: ConflictAlgorithm.replace, | |||
| ); | |||
| } | |||
| } | |||
| Future<void> updateMessageThreads({List<Conversation>? conversations}) async { | |||
| RSAPrivateKey privKey = await getPrivateKey(); | |||
| RSAPrivateKey privKey = await getPrivateKey(); | |||
| conversations ??= await getConversations(); | |||
| conversations ??= await getConversations(); | |||
| for (var i = 0; i < conversations.length; i++) { | |||
| await updateMessageThread(conversations[i], privKey: privKey); | |||
| } | |||
| for (var i = 0; i < conversations.length; i++) { | |||
| await updateMessageThread(conversations[i], privKey: privKey); | |||
| } | |||
| } | |||
| Future<void> sendMessage(Conversation conversation, String data) async { | |||
| final preferences = await SharedPreferences.getInstance(); | |||
| final userId = preferences.getString('userId'); | |||
| final username = preferences.getString('username'); | |||
| if (userId == null || username == null) { | |||
| throw Exception('Invalid user id'); | |||
| } | |||
| var uuid = const Uuid(); | |||
| final String messageDataId = uuid.v4(); | |||
| ConversationUser currentUser = await getConversationUserByUsername(conversation, username); | |||
| Message message = Message( | |||
| id: messageDataId, | |||
| symmetricKey: '', | |||
| userSymmetricKey: '', | |||
| senderId: userId, | |||
| senderUsername: username, | |||
| data: data, | |||
| createdAt: DateTime.now().toIso8601String(), | |||
| associationKey: currentUser.associationKey, | |||
| ); | |||
| final db = await getDatabaseConnection(); | |||
| print(await db.query('messages')); | |||
| await db.insert( | |||
| 'messages', | |||
| message.toMap(), | |||
| conflictAlgorithm: ConflictAlgorithm.replace, | |||
| ); | |||
| String messageJson = await message.toJson(conversation, messageDataId); | |||
| final resp = await http.post( | |||
| Uri.parse('${dotenv.env["SERVER_URL"]}api/v1/auth/message'), | |||
| headers: <String, String>{ | |||
| 'Content-Type': 'application/json; charset=UTF-8', | |||
| 'cookie': await getSessionCookie(), | |||
| }, | |||
| body: messageJson, | |||
| ); | |||
| // TODO: If statusCode not successfull, mark as needing resend | |||
| print(resp.statusCode); | |||
| } | |||
| @ -0,0 +1,8 @@ | |||
| import 'dart:math'; | |||
| String generateRandomString(int len) { | |||
| var r = Random(); | |||
| const _chars = 'AaBbCcDdEeFfGgHhIiJjKkLlMmNnOoPpQqRrSsTtUuVvWwXxYyZz1234567890'; | |||
| return List.generate(len, (index) => _chars[r.nextInt(_chars.length)]).join(); | |||
| } | |||
| @ -1,110 +1,133 @@ | |||
| import 'package:flutter/material.dart'; | |||
| import '/models/conversations.dart'; | |||
| import '/views/main/conversation_list_item.dart'; | |||
| import '/utils/storage/messages.dart'; | |||
| class ConversationList extends StatefulWidget { | |||
| const ConversationList({Key? key}) : super(key: key); | |||
| final List<Conversation> conversations; | |||
| const ConversationList({ | |||
| Key? key, | |||
| required this.conversations, | |||
| }) : super(key: key); | |||
| @override | |||
| State<ConversationList> createState() => _ConversationListState(); | |||
| @override | |||
| State<ConversationList> createState() => _ConversationListState(); | |||
| } | |||
| class _ConversationListState extends State<ConversationList> { | |||
| List<Conversation> conversations = []; | |||
| List<Conversation> conversations = []; | |||
| @override | |||
| void initState() { | |||
| super.initState(); | |||
| fetchConversations(); | |||
| } | |||
| void fetchConversations() async { | |||
| conversations = await getConversations(); | |||
| setState(() {}); | |||
| } | |||
| @override | |||
| void initState() { | |||
| super.initState(); | |||
| conversations.addAll(widget.conversations); | |||
| setState(() {}); | |||
| } | |||
| Widget list() { | |||
| void filterSearchResults(String query) { | |||
| List<Conversation> dummySearchList = []; | |||
| dummySearchList.addAll(widget.conversations); | |||
| if (conversations.isEmpty) { | |||
| return const Center( | |||
| child: Text('No Conversations'), | |||
| ); | |||
| if(query.isNotEmpty) { | |||
| List<Conversation> dummyListData = []; | |||
| dummySearchList.forEach((item) { | |||
| if (item.name.toLowerCase().contains(query)) { | |||
| dummyListData.add(item); | |||
| } | |||
| return ListView.builder( | |||
| itemCount: conversations.length, | |||
| shrinkWrap: true, | |||
| padding: const EdgeInsets.only(top: 16), | |||
| physics: const NeverScrollableScrollPhysics(), | |||
| itemBuilder: (context, i) { | |||
| return ConversationListItem( | |||
| conversation: conversations[i], | |||
| ); | |||
| }, | |||
| ); | |||
| }); | |||
| setState(() { | |||
| conversations.clear(); | |||
| conversations.addAll(dummyListData); | |||
| }); | |||
| return; | |||
| } | |||
| @override | |||
| Widget build(BuildContext context) { | |||
| return Scaffold( | |||
| body: SingleChildScrollView( | |||
| physics: const BouncingScrollPhysics(), | |||
| child: Column( | |||
| crossAxisAlignment: CrossAxisAlignment.start, | |||
| children: <Widget>[ | |||
| SafeArea( | |||
| child: Padding( | |||
| padding: const EdgeInsets.only(left: 16,right: 16,top: 10), | |||
| child: Row( | |||
| mainAxisAlignment: MainAxisAlignment.spaceBetween, | |||
| children: <Widget>[ | |||
| const Text("Conversations",style: TextStyle(fontSize: 32,fontWeight: FontWeight.bold),), | |||
| Container( | |||
| padding: const EdgeInsets.only(left: 8,right: 8,top: 2,bottom: 2), | |||
| height: 30, | |||
| decoration: BoxDecoration( | |||
| borderRadius: BorderRadius.circular(30), | |||
| color: Colors.pink[50], | |||
| ), | |||
| child: Row( | |||
| children: const <Widget>[ | |||
| Icon(Icons.add,color: Colors.pink,size: 20,), | |||
| SizedBox(width: 2,), | |||
| Text("Add",style: TextStyle(fontSize: 14,fontWeight: FontWeight.bold),), | |||
| ], | |||
| ), | |||
| ) | |||
| ], | |||
| ), | |||
| ), | |||
| ), | |||
| Padding( | |||
| padding: const EdgeInsets.only(top: 16,left: 16,right: 16), | |||
| child: TextField( | |||
| decoration: InputDecoration( | |||
| hintText: "Search...", | |||
| hintStyle: TextStyle(color: Colors.grey.shade600), | |||
| prefixIcon: Icon(Icons.search,color: Colors.grey.shade600, size: 20,), | |||
| filled: true, | |||
| fillColor: Colors.grey.shade100, | |||
| contentPadding: const EdgeInsets.all(8), | |||
| enabledBorder: OutlineInputBorder( | |||
| borderRadius: BorderRadius.circular(20), | |||
| borderSide: BorderSide( | |||
| color: Colors.grey.shade100 | |||
| ) | |||
| ), | |||
| ), | |||
| ), | |||
| ), | |||
| Padding( | |||
| padding: const EdgeInsets.only(top: 16,left: 16,right: 16), | |||
| child: list(), | |||
| ), | |||
| ], | |||
| ), | |||
| ), | |||
| ); | |||
| setState(() { | |||
| conversations.clear(); | |||
| conversations.addAll(widget.conversations); | |||
| }); | |||
| } | |||
| Widget list() { | |||
| if (conversations.isEmpty) { | |||
| return const Center( | |||
| child: Text('No Conversations'), | |||
| ); | |||
| } | |||
| return ListView.builder( | |||
| itemCount: conversations.length, | |||
| shrinkWrap: true, | |||
| padding: const EdgeInsets.only(top: 16), | |||
| physics: const NeverScrollableScrollPhysics(), | |||
| itemBuilder: (context, i) { | |||
| return ConversationListItem( | |||
| conversation: conversations[i], | |||
| ); | |||
| }, | |||
| ); | |||
| } | |||
| @override | |||
| Widget build(BuildContext context) { | |||
| return Scaffold( | |||
| body: SingleChildScrollView( | |||
| physics: const BouncingScrollPhysics(), | |||
| child: Column( | |||
| crossAxisAlignment: CrossAxisAlignment.start, | |||
| children: <Widget>[ | |||
| SafeArea( | |||
| child: Padding( | |||
| padding: const EdgeInsets.only(left: 16,right: 16,top: 10), | |||
| child: Row( | |||
| mainAxisAlignment: MainAxisAlignment.spaceBetween, | |||
| children: <Widget>[ | |||
| const Text("Conversations",style: TextStyle(fontSize: 32,fontWeight: FontWeight.bold),), | |||
| Container( | |||
| padding: const EdgeInsets.only(left: 8,right: 8,top: 2,bottom: 2), | |||
| height: 30, | |||
| decoration: BoxDecoration( | |||
| borderRadius: BorderRadius.circular(30), | |||
| color: Colors.pink[50], | |||
| ), | |||
| child: Row( | |||
| children: const <Widget>[ | |||
| Icon(Icons.add,color: Colors.pink,size: 20,), | |||
| SizedBox(width: 2,), | |||
| Text("Add",style: TextStyle(fontSize: 14,fontWeight: FontWeight.bold),), | |||
| ], | |||
| ), | |||
| ) | |||
| ], | |||
| ), | |||
| ), | |||
| ), | |||
| Padding( | |||
| padding: const EdgeInsets.only(top: 16,left: 16,right: 16), | |||
| child: TextField( | |||
| decoration: InputDecoration( | |||
| hintText: "Search...", | |||
| hintStyle: TextStyle(color: Colors.grey.shade600), | |||
| prefixIcon: Icon(Icons.search,color: Colors.grey.shade600, size: 20,), | |||
| filled: true, | |||
| fillColor: Colors.grey.shade100, | |||
| contentPadding: const EdgeInsets.all(8), | |||
| enabledBorder: OutlineInputBorder( | |||
| borderRadius: BorderRadius.circular(20), | |||
| borderSide: BorderSide( | |||
| color: Colors.grey.shade100 | |||
| ) | |||
| ), | |||
| ), | |||
| onChanged: (value) => filterSearchResults(value.toLowerCase()) | |||
| ), | |||
| ), | |||
| Padding( | |||
| padding: const EdgeInsets.only(top: 16,left: 16,right: 16), | |||
| child: list(), | |||
| ), | |||
| ], | |||
| ), | |||
| ), | |||
| ); | |||
| } | |||
| } | |||