From 4ca108dafaa1759ff76b8bc058121f85d53414cb Mon Sep 17 00:00:00 2001 From: Tovi Jaeschke-Rogers Date: Wed, 13 Jul 2022 20:33:30 +0930 Subject: [PATCH] Make local conversation records --- mobile/lib/models/conversations.dart | 226 +++++++++++------- mobile/lib/models/friends.dart | 2 + mobile/lib/models/my_profile.dart | 5 +- mobile/lib/utils/storage/database.dart | 1 - .../unauthenticated_landing.dart | 2 +- .../main/conversation_create_add_users.dart | 170 +++++++++++++ .../conversation_create_add_users_list.dart | 96 ++++++++ .../views/main/conversation_edit_details.dart | 146 +++++++++++ mobile/lib/views/main/conversation_list.dart | 32 ++- .../views/main/conversation_list_item.dart | 1 + mobile/lib/views/main/friend_list.dart | 3 +- mobile/lib/views/main/friend_list_item.dart | 25 +- mobile/lib/views/main/home.dart | 7 +- 13 files changed, 608 insertions(+), 108 deletions(-) create mode 100644 mobile/lib/views/main/conversation_create_add_users.dart create mode 100644 mobile/lib/views/main/conversation_create_add_users_list.dart create mode 100644 mobile/lib/views/main/conversation_edit_details.dart diff --git a/mobile/lib/models/conversations.dart b/mobile/lib/models/conversations.dart index ac18d89..45c4e1e 100644 --- a/mobile/lib/models/conversations.dart +++ b/mobile/lib/models/conversations.dart @@ -1,101 +1,163 @@ 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:sqflite/sqflite.dart'; +import 'package:uuid/uuid.dart'; import '/utils/encryption/crypto_utils.dart'; import '/utils/encryption/aes_helper.dart'; import '/utils/storage/database.dart'; +import '/utils/strings.dart'; Conversation findConversationByDetailId(List 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`. throw ArgumentError.value(id, "id", "No element with that id"); } 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 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 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 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 toMap() { + return { + 'id': id, + 'user_id': userId, + 'conversation_detail_id': conversationDetailId, + 'symmetric_key': symmetricKey, + 'admin': admin ? 1 : 0, + 'name': name, + }; + } } +Future createConversation(String title, List 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. Future> getConversations() async { - final db = await getDatabaseConnection(); - - final List> 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> 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'], + ); + }); } diff --git a/mobile/lib/models/friends.dart b/mobile/lib/models/friends.dart index 0b94b4a..d387e11 100644 --- a/mobile/lib/models/friends.dart +++ b/mobile/lib/models/friends.dart @@ -21,6 +21,7 @@ class Friend{ String friendSymmetricKey; String asymmetricPublicKey; String acceptedAt; + bool? selected; Friend({ required this.id, required this.userId, @@ -29,6 +30,7 @@ class Friend{ required this.friendSymmetricKey, required this.asymmetricPublicKey, required this.acceptedAt, + this.selected, }); factory Friend.fromJson(Map json, RSAPrivateKey privKey) { diff --git a/mobile/lib/models/my_profile.dart b/mobile/lib/models/my_profile.dart index 4e95cdb..ccd0eae 100644 --- a/mobile/lib/models/my_profile.dart +++ b/mobile/lib/models/my_profile.dart @@ -89,8 +89,9 @@ class MyProfile { if (profile.loggedInAt == null) { return false; } - return profile.loggedInAt!.isBefore( - (DateTime.now()).add(const Duration(hours: 12)) + + return profile.loggedInAt!.add(const Duration(hours: 12)).isAfter( + (DateTime.now()) ); } diff --git a/mobile/lib/utils/storage/database.dart b/mobile/lib/utils/storage/database.dart index 162a5c5..a760960 100644 --- a/mobile/lib/utils/storage/database.dart +++ b/mobile/lib/utils/storage/database.dart @@ -39,7 +39,6 @@ Future getDatabaseConnection() async { symmetric_key TEXT, admin INTEGER, name TEXT, - users TEXT ); '''); diff --git a/mobile/lib/views/authentication/unauthenticated_landing.dart b/mobile/lib/views/authentication/unauthenticated_landing.dart index adbdd19..85748ef 100644 --- a/mobile/lib/views/authentication/unauthenticated_landing.dart +++ b/mobile/lib/views/authentication/unauthenticated_landing.dart @@ -18,7 +18,7 @@ class _UnauthenticatedLandingWidgetState extends State friends; + final String title; + const ConversationAddFriendsList({ + Key? key, + required this.friends, + required this.title, + }) : super(key: key); + + @override + State createState() => _ConversationAddFriendsListState(); +} + +class _ConversationAddFriendsListState extends State { + List friends = []; + List friendsSelected = []; + + @override + void initState() { + super.initState(); + friends.addAll(widget.friends); + setState(() {}); + } + + void filterSearchResults(String query) { + List dummySearchList = []; + dummySearchList.addAll(widget.friends); + + if(query.isNotEmpty) { + List 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: [ + 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: [ + Text( + friendsSelected.isEmpty ? + 'Select Friends' : + '${friendsSelected.length} Friends Selected', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600 + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + body: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + 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), + ), + ), + ); + } +} diff --git a/mobile/lib/views/main/conversation_create_add_users_list.dart b/mobile/lib/views/main/conversation_create_add_users_list.dart new file mode 100644 index 0000000..6c53ac4 --- /dev/null +++ b/mobile/lib/views/main/conversation_create_add_users_list.dart @@ -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 isSelected; + const ConversationAddFriendItem({ + Key? key, + required this.friend, + required this.isSelected, + }) : super(key: key); + + @override + _ConversationAddFriendItemState createState() => _ConversationAddFriendItemState(); +} + +class _ConversationAddFriendItemState extends State { + @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: [ + Expanded( + child: Row( + children: [ + 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: [ + 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)) + ), + ) + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/views/main/conversation_edit_details.dart b/mobile/lib/views/main/conversation_edit_details.dart new file mode 100644 index 0000000..a882913 --- /dev/null +++ b/mobile/lib/views/main/conversation_edit_details.dart @@ -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? friends; + const ConversationEditDetails({ + Key? key, + this.conversation, + this.friends, + }) : super(key: key); + + @override + State createState() => _ConversationEditDetails(); +} + +class _ConversationEditDetails extends State { + final _formKey = GlobalKey(); + + List 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: [ + 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: [ + 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'), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/mobile/lib/views/main/conversation_list.dart b/mobile/lib/views/main/conversation_list.dart index 8ba20f7..b827ea6 100644 --- a/mobile/lib/views/main/conversation_list.dart +++ b/mobile/lib/views/main/conversation_list.dart @@ -1,12 +1,16 @@ +import 'package:Envelope/models/friends.dart'; +import 'package:Envelope/views/main/conversation_edit_details.dart'; import 'package:flutter/material.dart'; import '/models/conversations.dart'; import '/views/main/conversation_list_item.dart'; class ConversationList extends StatefulWidget { final List conversations; + final List friends; const ConversationList({ Key? key, required this.conversations, + required this.friends, }) : super(key: key); @override @@ -15,11 +19,13 @@ class ConversationList extends StatefulWidget { class _ConversationListState extends State { List conversations = []; + List friends = []; @override void initState() { super.initState(); conversations.addAll(widget.conversations); + friends.addAll(widget.friends); setState(() {}); } @@ -81,7 +87,13 @@ class _ConversationListState extends State { child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: const [ - Text("Conversations",style: TextStyle(fontSize: 32,fontWeight: FontWeight.bold),), + Text( + 'Conversations', + style: TextStyle( + fontSize: 32, + fontWeight: FontWeight.bold + ) + ), ], ), ), @@ -106,6 +118,24 @@ class _ConversationListState extends State { ], ), ), + floatingActionButton: Padding( + padding: const EdgeInsets.only(right: 10, bottom: 10), + child: FloatingActionButton( + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute(builder: (context) => ConversationEditDetails( + friends: friends, + )), + ).then(onGoBack); + }, + backgroundColor: Theme.of(context).colorScheme.primary, + child: const Icon(Icons.add, size: 30), + ), + ), ); } + + onGoBack(dynamic value) { + setState(() {}); + } } diff --git a/mobile/lib/views/main/conversation_list_item.dart b/mobile/lib/views/main/conversation_list_item.dart index ecfc157..885400f 100644 --- a/mobile/lib/views/main/conversation_list_item.dart +++ b/mobile/lib/views/main/conversation_list_item.dart @@ -19,6 +19,7 @@ class _ConversationListItemState extends State { @override Widget build(BuildContext context) { return GestureDetector( + behavior: HitTestBehavior.opaque, onTap: () { Navigator.push(context, MaterialPageRoute(builder: (context){ return ConversationDetail( diff --git a/mobile/lib/views/main/friend_list.dart b/mobile/lib/views/main/friend_list.dart index f331659..f1076ef 100644 --- a/mobile/lib/views/main/friend_list.dart +++ b/mobile/lib/views/main/friend_list.dart @@ -62,8 +62,7 @@ class _FriendListState extends State { physics: const NeverScrollableScrollPhysics(), itemBuilder: (context, i) { return FriendListItem( - id: friends[i].id, - username: friends[i].username, + friend: friends[i], ); }, ); diff --git a/mobile/lib/views/main/friend_list_item.dart b/mobile/lib/views/main/friend_list_item.dart index 3dece66..fd13204 100644 --- a/mobile/lib/views/main/friend_list_item.dart +++ b/mobile/lib/views/main/friend_list_item.dart @@ -1,15 +1,12 @@ import 'package:Envelope/components/custom_circle_avatar.dart'; +import 'package:Envelope/models/friends.dart'; import 'package:flutter/material.dart'; class FriendListItem extends StatefulWidget{ - final String id; - final String username; - final String? imagePath; + final Friend friend; const FriendListItem({ Key? key, - required this.id, - required this.username, - this.imagePath, + required this.friend, }) : super(key: key); @override @@ -21,7 +18,8 @@ class _FriendListItemState extends State { @override Widget build(BuildContext context) { return GestureDetector( - onTap: (){ + behavior: HitTestBehavior.opaque, + onTap: () async { }, child: Container( padding: const EdgeInsets.only(left: 16,right: 16,top: 10,bottom: 10), @@ -31,8 +29,8 @@ class _FriendListItemState extends State { child: Row( children: [ CustomCircleAvatar( - initials: widget.username[0].toUpperCase(), - imagePath: widget.imagePath, + initials: widget.friend.username[0].toUpperCase(), + imagePath: null, ), const SizedBox(width: 16), Expanded( @@ -43,14 +41,7 @@ class _FriendListItemState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(widget.username, style: const TextStyle(fontSize: 16)), - // Text( - // widget.messageText, - // style: TextStyle(fontSize: 13, - // color: Colors.grey.shade600, - // fontWeight: widget.isMessageRead?FontWeight.bold:FontWeight.normal - // ), - // ), + Text(widget.friend.username, style: const TextStyle(fontSize: 16)), ], ), ), diff --git a/mobile/lib/views/main/home.dart b/mobile/lib/views/main/home.dart index 85f4874..31a1a36 100644 --- a/mobile/lib/views/main/home.dart +++ b/mobile/lib/views/main/home.dart @@ -31,7 +31,7 @@ class _HomeState extends State { bool isLoading = true; int _selectedIndex = 0; List _widgetOptions = [ - const ConversationList(conversations: []), + const ConversationList(conversations: [], friends: []), const FriendList(friends: []), Profile( profile: MyProfile( @@ -61,7 +61,10 @@ class _HomeState extends State { setState(() { _widgetOptions = [ - ConversationList(conversations: conversations), + ConversationList( + conversations: conversations, + friends: friends, + ), FriendList(friends: friends), Profile(profile: profile), ];