| @ -1,101 +1,163 @@ | |||||
| import 'dart:convert'; | import 'dart:convert'; | ||||
| import 'dart:typed_data'; | |||||
| import 'package:Envelope/models/conversation_users.dart'; | |||||
| import 'package:Envelope/models/friends.dart'; | |||||
| import 'package:Envelope/models/my_profile.dart'; | |||||
| import 'package:pointycastle/export.dart'; | import 'package:pointycastle/export.dart'; | ||||
| import 'package:sqflite/sqflite.dart'; | |||||
| import 'package:uuid/uuid.dart'; | |||||
| import '/utils/encryption/crypto_utils.dart'; | import '/utils/encryption/crypto_utils.dart'; | ||||
| import '/utils/encryption/aes_helper.dart'; | import '/utils/encryption/aes_helper.dart'; | ||||
| import '/utils/storage/database.dart'; | import '/utils/storage/database.dart'; | ||||
| import '/utils/strings.dart'; | |||||
| Conversation findConversationByDetailId(List<Conversation> conversations, String id) { | Conversation findConversationByDetailId(List<Conversation> conversations, String id) { | ||||
| for (var conversation in conversations) { | |||||
| if (conversation.conversationDetailId == id) { | |||||
| return conversation; | |||||
| } | |||||
| for (var conversation in conversations) { | |||||
| if (conversation.conversationDetailId == id) { | |||||
| return conversation; | |||||
| } | } | ||||
| } | |||||
| // Or return `null`. | // Or return `null`. | ||||
| throw ArgumentError.value(id, "id", "No element with that id"); | throw ArgumentError.value(id, "id", "No element with that id"); | ||||
| } | } | ||||
| class Conversation { | class Conversation { | ||||
| String id; | |||||
| String userId; | |||||
| String conversationDetailId; | |||||
| String symmetricKey; | |||||
| bool admin; | |||||
| String name; | |||||
| Conversation({ | |||||
| required this.id, | |||||
| required this.userId, | |||||
| required this.conversationDetailId, | |||||
| required this.symmetricKey, | |||||
| required this.admin, | |||||
| required this.name, | |||||
| }); | |||||
| factory Conversation.fromJson(Map<String, dynamic> json, RSAPrivateKey privKey) { | |||||
| var symmetricKeyDecrypted = CryptoUtils.rsaDecrypt( | |||||
| base64.decode(json['symmetric_key']), | |||||
| privKey, | |||||
| ); | |||||
| var detailId = AesHelper.aesDecrypt( | |||||
| symmetricKeyDecrypted, | |||||
| base64.decode(json['conversation_detail_id']), | |||||
| ); | |||||
| var admin = AesHelper.aesDecrypt( | |||||
| symmetricKeyDecrypted, | |||||
| base64.decode(json['admin']), | |||||
| ); | |||||
| return Conversation( | |||||
| id: json['id'], | |||||
| userId: json['user_id'], | |||||
| conversationDetailId: detailId, | |||||
| symmetricKey: base64.encode(symmetricKeyDecrypted), | |||||
| admin: admin == 'true', | |||||
| name: 'Unknown', | |||||
| ); | |||||
| } | |||||
| @override | |||||
| String toString() { | |||||
| return ''' | |||||
| id: $id | |||||
| userId: $userId | |||||
| name: $name | |||||
| admin: $admin'''; | |||||
| } | |||||
| Map<String, dynamic> toMap() { | |||||
| return { | |||||
| 'id': id, | |||||
| 'user_id': userId, | |||||
| 'conversation_detail_id': conversationDetailId, | |||||
| 'symmetric_key': symmetricKey, | |||||
| 'admin': admin ? 1 : 0, | |||||
| 'name': name, | |||||
| }; | |||||
| } | |||||
| String id; | |||||
| String userId; | |||||
| String conversationDetailId; | |||||
| String symmetricKey; | |||||
| bool admin; | |||||
| String name; | |||||
| Conversation({ | |||||
| required this.id, | |||||
| required this.userId, | |||||
| required this.conversationDetailId, | |||||
| required this.symmetricKey, | |||||
| required this.admin, | |||||
| required this.name, | |||||
| }); | |||||
| factory Conversation.fromJson(Map<String, dynamic> json, RSAPrivateKey privKey) { | |||||
| var symmetricKeyDecrypted = CryptoUtils.rsaDecrypt( | |||||
| base64.decode(json['symmetric_key']), | |||||
| privKey, | |||||
| ); | |||||
| var detailId = AesHelper.aesDecrypt( | |||||
| symmetricKeyDecrypted, | |||||
| base64.decode(json['conversation_detail_id']), | |||||
| ); | |||||
| var admin = AesHelper.aesDecrypt( | |||||
| symmetricKeyDecrypted, | |||||
| base64.decode(json['admin']), | |||||
| ); | |||||
| return Conversation( | |||||
| id: json['id'], | |||||
| userId: json['user_id'], | |||||
| conversationDetailId: detailId, | |||||
| symmetricKey: base64.encode(symmetricKeyDecrypted), | |||||
| admin: admin == 'true', | |||||
| name: 'Unknown', | |||||
| ); | |||||
| } | |||||
| @override | |||||
| String toString() { | |||||
| return ''' | |||||
| id: $id | |||||
| userId: $userId | |||||
| name: $name | |||||
| admin: $admin'''; | |||||
| } | |||||
| Map<String, dynamic> toMap() { | |||||
| return { | |||||
| 'id': id, | |||||
| 'user_id': userId, | |||||
| 'conversation_detail_id': conversationDetailId, | |||||
| 'symmetric_key': symmetricKey, | |||||
| 'admin': admin ? 1 : 0, | |||||
| 'name': name, | |||||
| }; | |||||
| } | |||||
| } | } | ||||
| Future<Conversation> createConversation(String title, List<Friend> friends) async { | |||||
| final db = await getDatabaseConnection(); | |||||
| MyProfile profile = await MyProfile.getProfile(); | |||||
| var uuid = const Uuid(); | |||||
| final String conversationId = uuid.v4(); | |||||
| Uint8List symmetricKey = AesHelper.deriveKey(generateRandomString(32)); | |||||
| String associationKey = generateRandomString(32); | |||||
| Conversation conversation = Conversation( | |||||
| id: conversationId, | |||||
| userId: profile.id, | |||||
| conversationDetailId: '', | |||||
| symmetricKey: base64.encode(symmetricKey), | |||||
| admin: true, | |||||
| name: title, | |||||
| ); | |||||
| await db.insert( | |||||
| 'conversations', | |||||
| conversation.toMap(), | |||||
| conflictAlgorithm: ConflictAlgorithm.replace, | |||||
| ); | |||||
| await db.insert( | |||||
| 'conversation_users', | |||||
| ConversationUser( | |||||
| id: uuid.v4(), | |||||
| conversationId: conversationId, | |||||
| username: profile.username, | |||||
| associationKey: associationKey, | |||||
| admin: false, | |||||
| ).toMap(), | |||||
| conflictAlgorithm: ConflictAlgorithm.replace, | |||||
| ); | |||||
| for (Friend friend in friends) { | |||||
| await db.insert( | |||||
| 'conversation_users', | |||||
| ConversationUser( | |||||
| id: uuid.v4(), | |||||
| conversationId: conversationId, | |||||
| username: friend.username, | |||||
| associationKey: associationKey, | |||||
| admin: false, | |||||
| ).toMap(), | |||||
| conflictAlgorithm: ConflictAlgorithm.replace, | |||||
| ); | |||||
| } | |||||
| return conversation; | |||||
| } | |||||
| // A method that retrieves all the dogs from the dogs table. | // A method that retrieves all the dogs from the dogs table. | ||||
| Future<List<Conversation>> getConversations() async { | Future<List<Conversation>> getConversations() async { | ||||
| final db = await getDatabaseConnection(); | |||||
| final List<Map<String, dynamic>> maps = await db.query('conversations'); | |||||
| return List.generate(maps.length, (i) { | |||||
| return Conversation( | |||||
| id: maps[i]['id'], | |||||
| userId: maps[i]['user_id'], | |||||
| conversationDetailId: maps[i]['conversation_detail_id'], | |||||
| symmetricKey: maps[i]['symmetric_key'], | |||||
| admin: maps[i]['admin'] == 1, | |||||
| name: maps[i]['name'], | |||||
| ); | |||||
| }); | |||||
| final db = await getDatabaseConnection(); | |||||
| final List<Map<String, dynamic>> maps = await db.query('conversations'); | |||||
| return List.generate(maps.length, (i) { | |||||
| return Conversation( | |||||
| id: maps[i]['id'], | |||||
| userId: maps[i]['user_id'], | |||||
| conversationDetailId: maps[i]['conversation_detail_id'], | |||||
| symmetricKey: maps[i]['symmetric_key'], | |||||
| admin: maps[i]['admin'] == 1, | |||||
| name: maps[i]['name'], | |||||
| ); | |||||
| }); | |||||
| } | } | ||||
| @ -0,0 +1,170 @@ | |||||
| import 'package:Envelope/models/conversations.dart'; | |||||
| import 'package:Envelope/views/main/conversation_create_add_users_list.dart'; | |||||
| import 'package:Envelope/views/main/conversation_detail.dart'; | |||||
| import 'package:flutter/material.dart'; | |||||
| import '/models/friends.dart'; | |||||
| import '/views/main/friend_list_item.dart'; | |||||
| class ConversationAddFriendsList extends StatefulWidget { | |||||
| final List<Friend> friends; | |||||
| final String title; | |||||
| const ConversationAddFriendsList({ | |||||
| Key? key, | |||||
| required this.friends, | |||||
| required this.title, | |||||
| }) : super(key: key); | |||||
| @override | |||||
| State<ConversationAddFriendsList> createState() => _ConversationAddFriendsListState(); | |||||
| } | |||||
| class _ConversationAddFriendsListState extends State<ConversationAddFriendsList> { | |||||
| List<Friend> friends = []; | |||||
| List<Friend> friendsSelected = []; | |||||
| @override | |||||
| void initState() { | |||||
| super.initState(); | |||||
| friends.addAll(widget.friends); | |||||
| setState(() {}); | |||||
| } | |||||
| void filterSearchResults(String query) { | |||||
| List<Friend> dummySearchList = []; | |||||
| dummySearchList.addAll(widget.friends); | |||||
| if(query.isNotEmpty) { | |||||
| List<Friend> dummyListData = []; | |||||
| for (Friend friend in dummySearchList) { | |||||
| if (friend.username.toLowerCase().contains(query)) { | |||||
| dummyListData.add(friend); | |||||
| } | |||||
| } | |||||
| setState(() { | |||||
| friends.clear(); | |||||
| friends.addAll(dummyListData); | |||||
| }); | |||||
| return; | |||||
| } | |||||
| setState(() { | |||||
| friends.clear(); | |||||
| friends.addAll(widget.friends); | |||||
| }); | |||||
| } | |||||
| Widget list() { | |||||
| if (friends.isEmpty) { | |||||
| return const Center( | |||||
| child: Text('No Friends'), | |||||
| ); | |||||
| } | |||||
| return ListView.builder( | |||||
| itemCount: friends.length, | |||||
| shrinkWrap: true, | |||||
| padding: const EdgeInsets.only(top: 16), | |||||
| physics: const BouncingScrollPhysics(), | |||||
| itemBuilder: (context, i) { | |||||
| return ConversationAddFriendItem( | |||||
| friend: friends[i], | |||||
| isSelected: (bool value) { | |||||
| setState(() { | |||||
| widget.friends[i].selected = value; | |||||
| if (value) { | |||||
| friendsSelected.add(friends[i]); | |||||
| return; | |||||
| } | |||||
| friendsSelected.remove(friends[i]); | |||||
| }); | |||||
| } | |||||
| ); | |||||
| }, | |||||
| ); | |||||
| } | |||||
| @override | |||||
| Widget build(BuildContext context) { | |||||
| return Scaffold( | |||||
| appBar: AppBar( | |||||
| elevation: 0, | |||||
| automaticallyImplyLeading: false, | |||||
| flexibleSpace: SafeArea( | |||||
| child: Container( | |||||
| padding: const EdgeInsets.only(right: 16), | |||||
| child: Row( | |||||
| children: <Widget>[ | |||||
| IconButton( | |||||
| onPressed: (){ | |||||
| Navigator.pop(context); | |||||
| }, | |||||
| icon: const Icon(Icons.arrow_back), | |||||
| ), | |||||
| const SizedBox(width: 2,), | |||||
| Expanded( | |||||
| child: Column( | |||||
| crossAxisAlignment: CrossAxisAlignment.start, | |||||
| mainAxisAlignment: MainAxisAlignment.center, | |||||
| children: <Widget>[ | |||||
| Text( | |||||
| friendsSelected.isEmpty ? | |||||
| 'Select Friends' : | |||||
| '${friendsSelected.length} Friends Selected', | |||||
| style: const TextStyle( | |||||
| fontSize: 16, | |||||
| fontWeight: FontWeight.w600 | |||||
| ), | |||||
| ), | |||||
| ], | |||||
| ), | |||||
| ), | |||||
| ], | |||||
| ), | |||||
| ), | |||||
| ), | |||||
| ), | |||||
| body: Column( | |||||
| crossAxisAlignment: CrossAxisAlignment.start, | |||||
| children: <Widget>[ | |||||
| Padding( | |||||
| padding: const EdgeInsets.only(top: 20,left: 16,right: 16), | |||||
| child: TextField( | |||||
| decoration: const InputDecoration( | |||||
| hintText: "Search...", | |||||
| prefixIcon: Icon( | |||||
| Icons.search, | |||||
| size: 20 | |||||
| ), | |||||
| ), | |||||
| onChanged: (value) => filterSearchResults(value.toLowerCase()) | |||||
| ), | |||||
| ), | |||||
| Padding( | |||||
| padding: const EdgeInsets.only(top: 0,left: 16,right: 16), | |||||
| child: list(), | |||||
| ), | |||||
| ], | |||||
| ), | |||||
| floatingActionButton: Padding( | |||||
| padding: const EdgeInsets.only(right: 10, bottom: 10), | |||||
| child: FloatingActionButton( | |||||
| onPressed: () async { | |||||
| Conversation conversation = await createConversation(widget.title, friendsSelected); | |||||
| friendsSelected = []; | |||||
| Navigator.of(context).popUntil((route) => route.isFirst); | |||||
| Navigator.push(context, MaterialPageRoute(builder: (context){ | |||||
| return ConversationDetail( | |||||
| conversation: conversation, | |||||
| ); | |||||
| })); | |||||
| }, | |||||
| backgroundColor: Theme.of(context).colorScheme.primary, | |||||
| child: friendsSelected.isEmpty ? | |||||
| const Text('Skip') : | |||||
| const Icon(Icons.add, size: 30), | |||||
| ), | |||||
| ), | |||||
| ); | |||||
| } | |||||
| } | |||||
| @ -0,0 +1,96 @@ | |||||
| import 'package:Envelope/components/custom_circle_avatar.dart'; | |||||
| import 'package:Envelope/models/friends.dart'; | |||||
| import 'package:flutter/material.dart'; | |||||
| class ConversationAddFriendItem extends StatefulWidget{ | |||||
| final Friend friend; | |||||
| final ValueChanged<bool> isSelected; | |||||
| const ConversationAddFriendItem({ | |||||
| Key? key, | |||||
| required this.friend, | |||||
| required this.isSelected, | |||||
| }) : super(key: key); | |||||
| @override | |||||
| _ConversationAddFriendItemState createState() => _ConversationAddFriendItemState(); | |||||
| } | |||||
| class _ConversationAddFriendItemState extends State<ConversationAddFriendItem> { | |||||
| @override | |||||
| void initState() { | |||||
| super.initState(); | |||||
| } | |||||
| @override | |||||
| Widget build(BuildContext context) { | |||||
| return GestureDetector( | |||||
| behavior: HitTestBehavior.opaque, | |||||
| onTap: () async { | |||||
| setState(() { | |||||
| widget.friend.selected = !(widget.friend.selected ?? false); | |||||
| widget.isSelected(widget.friend.selected ?? false); | |||||
| }); | |||||
| }, | |||||
| child: Container( | |||||
| padding: const EdgeInsets.only(left: 16,right: 16,top: 10,bottom: 10), | |||||
| child: Row( | |||||
| children: <Widget>[ | |||||
| Expanded( | |||||
| child: Row( | |||||
| children: <Widget>[ | |||||
| CustomCircleAvatar( | |||||
| initials: widget.friend.username[0].toUpperCase(), | |||||
| imagePath: null, | |||||
| ), | |||||
| const SizedBox(width: 16), | |||||
| Expanded( | |||||
| child: Align( | |||||
| alignment: Alignment.centerLeft, | |||||
| child: Container( | |||||
| color: Colors.transparent, | |||||
| child: Column( | |||||
| crossAxisAlignment: CrossAxisAlignment.start, | |||||
| children: <Widget>[ | |||||
| Text( | |||||
| widget.friend.username, | |||||
| style: const TextStyle( | |||||
| fontSize: 16 | |||||
| ) | |||||
| ), | |||||
| ], | |||||
| ), | |||||
| ), | |||||
| ), | |||||
| ), | |||||
| (widget.friend.selected ?? false) | |||||
| ? Align( | |||||
| alignment: Alignment.centerRight, | |||||
| child: Icon( | |||||
| Icons.check_circle, | |||||
| color: Theme.of(context).colorScheme.primary, | |||||
| size: 36, | |||||
| ), | |||||
| ) | |||||
| : Padding( | |||||
| padding: const EdgeInsets.only(right: 3), | |||||
| child: Container( | |||||
| width: 30, | |||||
| height: 30, | |||||
| decoration: BoxDecoration( | |||||
| border: Border.all( | |||||
| color: Theme.of(context).colorScheme.primary, | |||||
| width: 2, | |||||
| ), | |||||
| borderRadius: const BorderRadius.all(Radius.circular(100)) | |||||
| ), | |||||
| ) | |||||
| ), | |||||
| ], | |||||
| ), | |||||
| ), | |||||
| ], | |||||
| ), | |||||
| ), | |||||
| ); | |||||
| } | |||||
| } | |||||
| @ -0,0 +1,146 @@ | |||||
| import 'package:flutter/material.dart'; | |||||
| import '/components/custom_circle_avatar.dart'; | |||||
| import '/views/main/conversation_create_add_users.dart'; | |||||
| import '/models/friends.dart'; | |||||
| import '/models/conversations.dart'; | |||||
| class ConversationEditDetails extends StatefulWidget { | |||||
| final Conversation? conversation; | |||||
| final List<Friend>? friends; | |||||
| const ConversationEditDetails({ | |||||
| Key? key, | |||||
| this.conversation, | |||||
| this.friends, | |||||
| }) : super(key: key); | |||||
| @override | |||||
| State<ConversationEditDetails> createState() => _ConversationEditDetails(); | |||||
| } | |||||
| class _ConversationEditDetails extends State<ConversationEditDetails> { | |||||
| final _formKey = GlobalKey<FormState>(); | |||||
| List<Conversation> conversations = []; | |||||
| TextEditingController conversationNameController = TextEditingController(); | |||||
| @override | |||||
| Widget build(BuildContext context) { | |||||
| const TextStyle inputTextStyle = TextStyle( | |||||
| fontSize: 25, | |||||
| ); | |||||
| final OutlineInputBorder inputBorderStyle = OutlineInputBorder( | |||||
| borderRadius: BorderRadius.circular(5), | |||||
| borderSide: const BorderSide( | |||||
| color: Colors.transparent, | |||||
| ) | |||||
| ); | |||||
| final ButtonStyle buttonStyle = ElevatedButton.styleFrom( | |||||
| padding: const EdgeInsets.symmetric(horizontal: 30, vertical: 10), | |||||
| textStyle: TextStyle( | |||||
| fontSize: 15, | |||||
| fontWeight: FontWeight.bold, | |||||
| color: Theme.of(context).colorScheme.error, | |||||
| ), | |||||
| ); | |||||
| return Scaffold( | |||||
| appBar: AppBar( | |||||
| elevation: 0, | |||||
| automaticallyImplyLeading: false, | |||||
| flexibleSpace: SafeArea( | |||||
| child: Container( | |||||
| padding: const EdgeInsets.only(right: 16), | |||||
| child: Row( | |||||
| children: <Widget>[ | |||||
| IconButton( | |||||
| onPressed: (){ | |||||
| Navigator.pop(context); | |||||
| }, | |||||
| icon: const Icon(Icons.arrow_back), | |||||
| ), | |||||
| const SizedBox(width: 2,), | |||||
| Expanded( | |||||
| child: Column( | |||||
| crossAxisAlignment: CrossAxisAlignment.start, | |||||
| mainAxisAlignment: MainAxisAlignment.center, | |||||
| children: <Widget>[ | |||||
| Text( | |||||
| widget.conversation != null ? | |||||
| widget.conversation!.name + " Settings" : | |||||
| 'Add Conversation', | |||||
| style: const TextStyle( | |||||
| fontSize: 16, | |||||
| fontWeight: FontWeight.w600 | |||||
| ), | |||||
| ), | |||||
| ], | |||||
| ), | |||||
| ), | |||||
| ], | |||||
| ), | |||||
| ), | |||||
| ), | |||||
| ), | |||||
| body: Center( | |||||
| child: Padding( | |||||
| padding: const EdgeInsets.only( | |||||
| top: 50, | |||||
| left: 25, | |||||
| right: 25, | |||||
| ), | |||||
| child: Form( | |||||
| key: _formKey, | |||||
| child: Column( | |||||
| children: [ | |||||
| CustomCircleAvatar( | |||||
| icon: widget.conversation != null ? | |||||
| null : // TODO: Add icon here | |||||
| const Icon(Icons.people, size: 60), | |||||
| imagePath: null, | |||||
| radius: 50, | |||||
| ), | |||||
| const SizedBox(height: 30), | |||||
| TextFormField( | |||||
| controller: conversationNameController, | |||||
| textAlign: TextAlign.center, | |||||
| decoration: InputDecoration( | |||||
| hintText: 'Title', | |||||
| enabledBorder: inputBorderStyle, | |||||
| focusedBorder: inputBorderStyle, | |||||
| ), | |||||
| style: inputTextStyle, | |||||
| // The validator receives the text that the user has entered. | |||||
| validator: (value) { | |||||
| if (value == null || value.isEmpty) { | |||||
| return 'Add a title'; | |||||
| } | |||||
| return null; | |||||
| }, | |||||
| ), | |||||
| const SizedBox(height: 30), | |||||
| ElevatedButton( | |||||
| style: buttonStyle, | |||||
| onPressed: () { | |||||
| if (_formKey.currentState!.validate()) { | |||||
| Navigator.of(context).push( | |||||
| MaterialPageRoute(builder: (context) => ConversationAddFriendsList( | |||||
| friends: widget.friends!, | |||||
| title: conversationNameController.text, | |||||
| ) | |||||
| ) | |||||
| ); | |||||
| } | |||||
| }, | |||||
| child: const Text('Save'), | |||||
| ), | |||||
| ], | |||||
| ), | |||||
| ), | |||||
| ), | |||||
| ), | |||||
| ); | |||||
| } | |||||
| } | |||||