Browse Source

Accept and reject friend requests

Added custom error message for when interactions fail
pull/1/head
Tovi Jaeschke-Rogers 2 years ago
parent
commit
1f4d26165f
33 changed files with 1509 additions and 505 deletions
  1. +75
    -0
      Backend/Api/Friends/AcceptFriendRequest.go
  2. +5
    -4
      Backend/Api/Friends/EncryptedFriendsList.go
  3. +6
    -27
      Backend/Api/Friends/Friends.go
  4. +44
    -0
      Backend/Api/Friends/RejectFriendRequest.go
  5. +8
    -6
      Backend/Api/Messages/Conversations.go
  6. +5
    -4
      Backend/Api/Messages/MessageThread.go
  7. +19
    -15
      Backend/Api/Routes.go
  8. +56
    -0
      Backend/Api/Users/SearchUsers.go
  9. +17
    -7
      Backend/Database/FriendRequests.go
  10. +24
    -11
      Backend/Database/Seeder/FriendSeeder.go
  11. +18
    -22
      Backend/Database/Users.go
  12. +9
    -10
      Backend/Models/Friends.go
  13. +1
    -1
      Backend/main.go
  14. +1
    -0
      mobile/analysis_options.yaml
  15. +213
    -0
      mobile/lib/components/custom_expandable_fab.dart
  16. +72
    -0
      mobile/lib/components/custom_title_bar.dart
  17. +60
    -0
      mobile/lib/components/flash_message.dart
  18. +137
    -0
      mobile/lib/components/user_search_result.dart
  19. +20
    -0
      mobile/lib/data_models/user_search.dart
  20. +4
    -4
      mobile/lib/main.dart
  21. +76
    -60
      mobile/lib/models/friends.dart
  22. +25
    -23
      mobile/lib/utils/storage/conversations.dart
  23. +4
    -5
      mobile/lib/utils/storage/friends.dart
  24. +22
    -19
      mobile/lib/views/authentication/signup.dart
  25. +12
    -39
      mobile/lib/views/main/conversation/detail.dart
  26. +63
    -73
      mobile/lib/views/main/conversation/list.dart
  27. +9
    -32
      mobile/lib/views/main/conversation/settings.dart
  28. +151
    -0
      mobile/lib/views/main/friend/add_search.dart
  29. +109
    -66
      mobile/lib/views/main/friend/list.dart
  30. +32
    -32
      mobile/lib/views/main/friend/list_item.dart
  31. +180
    -0
      mobile/lib/views/main/friend/request_list_item.dart
  32. +5
    -4
      mobile/lib/views/main/home.dart
  33. +27
    -41
      mobile/lib/views/main/profile/profile.dart

+ 75
- 0
Backend/Api/Friends/AcceptFriendRequest.go View File

@ -0,0 +1,75 @@
package Friends
import (
"encoding/json"
"io/ioutil"
"net/http"
"time"
"git.tovijaeschke.xyz/tovi/Envelope/Backend/Database"
"git.tovijaeschke.xyz/tovi/Envelope/Backend/Models"
"github.com/gorilla/mux"
)
// AcceptFriendRequest accepts friend requests
func AcceptFriendRequest(w http.ResponseWriter, r *http.Request) {
var (
oldFriendRequest Models.FriendRequest
newFriendRequest Models.FriendRequest
urlVars map[string]string
friendRequestID string
requestBody []byte
ok bool
err error
)
urlVars = mux.Vars(r)
friendRequestID, ok = urlVars["requestID"]
if !ok {
http.Error(w, "Not Found", http.StatusNotFound)
return
}
oldFriendRequest, err = Database.GetFriendRequestByID(friendRequestID)
if err != nil {
http.Error(w, "Error", http.StatusInternalServerError)
w.WriteHeader(http.StatusInternalServerError)
return
}
oldFriendRequest.AcceptedAt.Time = time.Now()
oldFriendRequest.AcceptedAt.Valid = true
requestBody, err = ioutil.ReadAll(r.Body)
if err != nil {
http.Error(w, "Error", http.StatusInternalServerError)
w.WriteHeader(http.StatusInternalServerError)
return
}
err = json.Unmarshal(requestBody, &newFriendRequest)
if err != nil {
http.Error(w, "Error", http.StatusInternalServerError)
w.WriteHeader(http.StatusInternalServerError)
return
}
err = Database.UpdateFriendRequest(&oldFriendRequest)
if err != nil {
http.Error(w, "Error", http.StatusInternalServerError)
w.WriteHeader(http.StatusInternalServerError)
return
}
newFriendRequest.AcceptedAt.Time = time.Now()
newFriendRequest.AcceptedAt.Valid = true
err = Database.CreateFriendRequest(&newFriendRequest)
if err != nil {
http.Error(w, "Error", http.StatusInternalServerError)
w.WriteHeader(http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}

+ 5
- 4
Backend/Api/Friends/EncryptedFriendsList.go View File

@ -9,11 +9,12 @@ import (
"git.tovijaeschke.xyz/tovi/Envelope/Backend/Models"
)
// EncryptedFriendRequestList gets friend request list
func EncryptedFriendRequestList(w http.ResponseWriter, r *http.Request) {
var (
userSession Models.Session
friends []Models.FriendRequest
returnJson []byte
returnJSON []byte
err error
)
@ -23,18 +24,18 @@ func EncryptedFriendRequestList(w http.ResponseWriter, r *http.Request) {
return
}
friends, err = Database.GetFriendRequestsByUserId(userSession.UserID.String())
friends, err = Database.GetFriendRequestsByUserID(userSession.UserID.String())
if err != nil {
http.Error(w, "Error", http.StatusInternalServerError)
return
}
returnJson, err = json.MarshalIndent(friends, "", " ")
returnJSON, err = json.MarshalIndent(friends, "", " ")
if err != nil {
http.Error(w, "Error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
w.Write(returnJson)
w.Write(returnJSON)
}

+ 6
- 27
Backend/Api/Friends/Friends.go View File

@ -7,37 +7,14 @@ import (
"git.tovijaeschke.xyz/tovi/Envelope/Backend/Database"
"git.tovijaeschke.xyz/tovi/Envelope/Backend/Models"
"git.tovijaeschke.xyz/tovi/Envelope/Backend/Util"
)
func Friend(w http.ResponseWriter, r *http.Request) {
var (
userData Models.User
returnJson []byte
err error
)
userData, err = Util.GetUserById(w, r)
if err != nil {
http.Error(w, "Not Found", http.StatusNotFound)
return
}
returnJson, err = json.MarshalIndent(userData, "", " ")
if err != nil {
http.Error(w, "Error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
w.Write(returnJson)
}
// CreateFriendRequest creates a FriendRequest from post data
func CreateFriendRequest(w http.ResponseWriter, r *http.Request) {
var (
friendRequest Models.FriendRequest
requestBody []byte
returnJson []byte
returnJSON []byte
err error
)
@ -51,12 +28,14 @@ func CreateFriendRequest(w http.ResponseWriter, r *http.Request) {
panic(err)
}
friendRequest.AcceptedAt.Scan(nil)
err = Database.CreateFriendRequest(&friendRequest)
if err != nil {
panic(err)
}
returnJson, err = json.MarshalIndent(friendRequest, "", " ")
returnJSON, err = json.MarshalIndent(friendRequest, "", " ")
if err != nil {
http.Error(w, "Error", http.StatusInternalServerError)
w.WriteHeader(http.StatusInternalServerError)
@ -65,5 +44,5 @@ func CreateFriendRequest(w http.ResponseWriter, r *http.Request) {
// Return updated json
w.WriteHeader(http.StatusOK)
w.Write(returnJson)
w.Write(returnJSON)
}

+ 44
- 0
Backend/Api/Friends/RejectFriendRequest.go View File

@ -0,0 +1,44 @@
package Friends
import (
"net/http"
"git.tovijaeschke.xyz/tovi/Envelope/Backend/Database"
"git.tovijaeschke.xyz/tovi/Envelope/Backend/Models"
"github.com/gorilla/mux"
)
// RejectFriendRequest rejects friend requests
func RejectFriendRequest(w http.ResponseWriter, r *http.Request) {
var (
friendRequest Models.FriendRequest
urlVars map[string]string
friendRequestID string
ok bool
err error
)
urlVars = mux.Vars(r)
friendRequestID, ok = urlVars["requestID"]
if !ok {
http.Error(w, "Not Found", http.StatusNotFound)
return
}
friendRequest, err = Database.GetFriendRequestByID(friendRequestID)
if err != nil {
http.Error(w, "Error", http.StatusInternalServerError)
w.WriteHeader(http.StatusInternalServerError)
return
}
err = Database.DeleteFriendRequest(&friendRequest)
if err != nil {
http.Error(w, "Error", http.StatusInternalServerError)
w.WriteHeader(http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}

+ 8
- 6
Backend/Api/Messages/Conversations.go View File

@ -11,11 +11,12 @@ import (
"git.tovijaeschke.xyz/tovi/Envelope/Backend/Models"
)
// EncryptedConversationList returns an encrypted list of all Conversations
func EncryptedConversationList(w http.ResponseWriter, r *http.Request) {
var (
userConversations []Models.UserConversation
userSession Models.Session
returnJson []byte
returnJSON []byte
err error
)
@ -33,22 +34,23 @@ func EncryptedConversationList(w http.ResponseWriter, r *http.Request) {
return
}
returnJson, err = json.MarshalIndent(userConversations, "", " ")
returnJSON, err = json.MarshalIndent(userConversations, "", " ")
if err != nil {
http.Error(w, "Error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
w.Write(returnJson)
w.Write(returnJSON)
}
// EncryptedConversationDetailsList returns an encrypted list of all ConversationDetails
func EncryptedConversationDetailsList(w http.ResponseWriter, r *http.Request) {
var (
userConversations []Models.ConversationDetail
query url.Values
conversationIds []string
returnJson []byte
returnJSON []byte
ok bool
err error
)
@ -71,12 +73,12 @@ func EncryptedConversationDetailsList(w http.ResponseWriter, r *http.Request) {
return
}
returnJson, err = json.MarshalIndent(userConversations, "", " ")
returnJSON, err = json.MarshalIndent(userConversations, "", " ")
if err != nil {
http.Error(w, "Error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
w.Write(returnJson)
w.Write(returnJSON)
}

+ 5
- 4
Backend/Api/Messages/MessageThread.go View File

@ -10,18 +10,19 @@ import (
"github.com/gorilla/mux"
)
// Messages gets messages by the associationKey
func Messages(w http.ResponseWriter, r *http.Request) {
var (
messages []Models.Message
urlVars map[string]string
associationKey string
returnJson []byte
returnJSON []byte
ok bool
err error
)
urlVars = mux.Vars(r)
associationKey, ok = urlVars["threadKey"]
associationKey, ok = urlVars["associationKey"]
if !ok {
http.Error(w, "Not Found", http.StatusNotFound)
return
@ -33,12 +34,12 @@ func Messages(w http.ResponseWriter, r *http.Request) {
return
}
returnJson, err = json.MarshalIndent(messages, "", " ")
returnJSON, err = json.MarshalIndent(messages, "", " ")
if err != nil {
http.Error(w, "Error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
w.Write(returnJson)
w.Write(returnJSON)
}

+ 19
- 15
Backend/Api/Routes.go View File

@ -7,6 +7,7 @@ import (
"git.tovijaeschke.xyz/tovi/Envelope/Backend/Api/Auth"
"git.tovijaeschke.xyz/tovi/Envelope/Backend/Api/Friends"
"git.tovijaeschke.xyz/tovi/Envelope/Backend/Api/Messages"
"git.tovijaeschke.xyz/tovi/Envelope/Backend/Api/Users"
"github.com/gorilla/mux"
)
@ -38,10 +39,11 @@ func authenticationMiddleware(next http.Handler) http.Handler {
})
}
func InitApiEndpoints(router *mux.Router) {
// InitAPIEndpoints initializes all API endpoints required by mobile app
func InitAPIEndpoints(router *mux.Router) {
var (
api *mux.Router
authApi *mux.Router
authAPI *mux.Router
)
log.Println("Initializing API routes...")
@ -54,22 +56,24 @@ func InitApiEndpoints(router *mux.Router) {
api.HandleFunc("/login", Auth.Login).Methods("POST")
api.HandleFunc("/logout", Auth.Logout).Methods("GET")
authApi = api.PathPrefix("/auth/").Subrouter()
authApi.Use(authenticationMiddleware)
authAPI = api.PathPrefix("/auth/").Subrouter()
authAPI.Use(authenticationMiddleware)
authApi.HandleFunc("/check", Auth.Check).Methods("GET")
authAPI.HandleFunc("/check", Auth.Check).Methods("GET")
// Define routes for friends and friend requests
authApi.HandleFunc("/friend_requests", Friends.EncryptedFriendRequestList).Methods("GET")
authApi.HandleFunc("/friend_request", Friends.CreateFriendRequest).Methods("POST")
authAPI.HandleFunc("/users", Users.SearchUsers).Methods("GET")
authApi.HandleFunc("/conversations", Messages.EncryptedConversationList).Methods("GET")
authApi.HandleFunc("/conversation_details", Messages.EncryptedConversationDetailsList).Methods("GET")
authAPI.HandleFunc("/friend_requests", Friends.EncryptedFriendRequestList).Methods("GET")
authAPI.HandleFunc("/friend_request", Friends.CreateFriendRequest).Methods("POST")
authAPI.HandleFunc("/friend_request/{requestID}", Friends.AcceptFriendRequest).Methods("POST")
authAPI.HandleFunc("/friend_request/{requestID}", Friends.RejectFriendRequest).Methods("DELETE")
authApi.HandleFunc("/conversations", Messages.CreateConversation).Methods("POST")
authApi.HandleFunc("/conversations", Messages.UpdateConversation).Methods("PUT")
authAPI.HandleFunc("/conversations", Messages.EncryptedConversationList).Methods("GET")
authAPI.HandleFunc("/conversation_details", Messages.EncryptedConversationDetailsList).Methods("GET")
// Define routes for messages
authApi.HandleFunc("/message", Messages.CreateMessage).Methods("POST")
authApi.HandleFunc("/messages/{threadKey}", Messages.Messages).Methods("GET")
authAPI.HandleFunc("/conversations", Messages.CreateConversation).Methods("POST")
authAPI.HandleFunc("/conversations", Messages.UpdateConversation).Methods("PUT")
authAPI.HandleFunc("/message", Messages.CreateMessage).Methods("POST")
authAPI.HandleFunc("/messages/{associationKey}", Messages.Messages).Methods("GET")
}

+ 56
- 0
Backend/Api/Users/SearchUsers.go View File

@ -0,0 +1,56 @@
package Users
import (
"encoding/json"
"net/http"
"net/url"
"git.tovijaeschke.xyz/tovi/Envelope/Backend/Database"
"git.tovijaeschke.xyz/tovi/Envelope/Backend/Models"
)
// SearchUsers searches a for a user by username
func SearchUsers(w http.ResponseWriter, r *http.Request) {
var (
user Models.User
query url.Values
rawUsername []string
username string
returnJSON []byte
ok bool
err error
)
query = r.URL.Query()
rawUsername, ok = query["username"]
if !ok {
http.Error(w, "Not Found", http.StatusNotFound)
return
}
if len(rawUsername) != 1 {
http.Error(w, "Not Found", http.StatusNotFound)
return
}
username = rawUsername[0]
user, err = Database.GetUserByUsername(username)
if err != nil {
http.Error(w, "Not Found", http.StatusNotFound)
return
}
user.Password = ""
user.AsymmetricPrivateKey = ""
returnJSON, err = json.MarshalIndent(user, "", " ")
if err != nil {
panic(err)
http.Error(w, "Not Found", http.StatusNotFound)
return
}
w.WriteHeader(http.StatusOK)
w.Write(returnJSON)
}

+ 17
- 7
Backend/Database/FriendRequests.go View File

@ -7,7 +7,8 @@ import (
"gorm.io/gorm/clause"
)
func GetFriendRequestById(id string) (Models.FriendRequest, error) {
// GetFriendRequestByID gets friend request
func GetFriendRequestByID(id string) (Models.FriendRequest, error) {
var (
friendRequest Models.FriendRequest
err error
@ -20,7 +21,8 @@ func GetFriendRequestById(id string) (Models.FriendRequest, error) {
return friendRequest, err
}
func GetFriendRequestsByUserId(userID string) ([]Models.FriendRequest, error) {
// GetFriendRequestsByUserID gets friend request by user id
func GetFriendRequestsByUserID(userID string) ([]Models.FriendRequest, error) {
var (
friends []Models.FriendRequest
err error
@ -34,14 +36,22 @@ func GetFriendRequestsByUserId(userID string) ([]Models.FriendRequest, error) {
return friends, err
}
func CreateFriendRequest(FriendRequest *Models.FriendRequest) error {
return DB.Session(&gorm.Session{FullSaveAssociations: true}).
Create(FriendRequest).
// CreateFriendRequest creates friend request
func CreateFriendRequest(friendRequest *Models.FriendRequest) error {
return DB.Create(friendRequest).
Error
}
// UpdateFriendRequest Updates friend request
func UpdateFriendRequest(friendRequest *Models.FriendRequest) error {
return DB.Where("id = ?", friendRequest.ID).
Updates(friendRequest).
Error
}
func DeleteFriendRequest(FriendRequest *Models.FriendRequest) error {
// DeleteFriendRequest deletes friend request
func DeleteFriendRequest(friendRequest *Models.FriendRequest) error {
return DB.Session(&gorm.Session{FullSaveAssociations: true}).
Delete(FriendRequest).
Delete(friendRequest).
Error
}

+ 24
- 11
Backend/Database/Seeder/FriendSeeder.go View File

@ -8,7 +8,7 @@ import (
"git.tovijaeschke.xyz/tovi/Envelope/Backend/Models"
)
func seedFriend(userRequestTo, userRequestFrom Models.User) error {
func seedFriend(userRequestTo, userRequestFrom Models.User, accepted bool) error {
var (
friendRequest Models.FriendRequest
symKey aesKey
@ -27,9 +27,7 @@ func seedFriend(userRequestTo, userRequestFrom Models.User) error {
}
friendRequest = Models.FriendRequest{
UserID: userRequestTo.ID,
UserUsername: userRequestTo.Username,
AcceptedAt: time.Now(),
UserID: userRequestTo.ID,
FriendID: base64.StdEncoding.EncodeToString(
encryptWithPublicKey(
[]byte(userRequestFrom.ID.String()),
@ -50,13 +48,20 @@ func seedFriend(userRequestTo, userRequestFrom Models.User) error {
),
}
if accepted {
friendRequest.AcceptedAt.Time = time.Now()
friendRequest.AcceptedAt.Valid = true
}
return Database.CreateFriendRequest(&friendRequest)
}
// SeedFriends creates dummy friends for testing/development
func SeedFriends() {
var (
primaryUser Models.User
secondaryUser Models.User
accepted bool
i int
err error
)
@ -71,30 +76,38 @@ func SeedFriends() {
panic(err)
}
err = seedFriend(primaryUser, secondaryUser)
err = seedFriend(primaryUser, secondaryUser, true)
if err != nil {
panic(err)
}
err = seedFriend(secondaryUser, primaryUser)
err = seedFriend(secondaryUser, primaryUser, true)
if err != nil {
panic(err)
}
for i = 0; i <= 3; i++ {
accepted = false
for i = 0; i <= 5; i++ {
secondaryUser, err = Database.GetUserByUsername(userNames[i])
if err != nil {
panic(err)
}
err = seedFriend(primaryUser, secondaryUser)
if err != nil {
panic(err)
if i > 3 {
accepted = true
}
err = seedFriend(secondaryUser, primaryUser)
err = seedFriend(primaryUser, secondaryUser, accepted)
if err != nil {
panic(err)
}
if accepted {
err = seedFriend(secondaryUser, primaryUser, accepted)
if err != nil {
panic(err)
}
}
}
}

+ 18
- 22
Backend/Database/Users.go View File

@ -11,28 +11,28 @@ import (
func GetUserById(id string) (Models.User, error) {
var (
userData Models.User
err error
user Models.User
err error
)
err = DB.Preload(clause.Associations).
First(&userData, "id = ?", id).
First(&user, "id = ?", id).
Error
return userData, err
return user, err
}
func GetUserByUsername(username string) (Models.User, error) {
var (
userData Models.User
err error
user Models.User
err error
)
err = DB.Preload(clause.Associations).
First(&userData, "username = ?", username).
First(&user, "username = ?", username).
Error
return userData, err
return user, err
}
func CheckUniqueUsername(username string) error {
@ -58,26 +58,22 @@ func CheckUniqueUsername(username string) error {
return nil
}
func CreateUser(userData *Models.User) error {
var (
err error
)
func CreateUser(user *Models.User) error {
var err error
err = DB.Session(&gorm.Session{FullSaveAssociations: true}).
Create(userData).
Create(user).
Error
return err
}
func UpdateUser(id string, userData *Models.User) error {
var (
err error
)
err = DB.Model(&userData).
func UpdateUser(id string, user *Models.User) error {
var err error
err = DB.Model(&user).
Omit("id").
Where("id = ?", id).
Updates(userData).
Updates(user).
Error
if err != nil {
@ -86,14 +82,14 @@ func UpdateUser(id string, userData *Models.User) error {
err = DB.Model(Models.User{}).
Where("id = ?", id).
First(userData).
First(user).
Error
return err
}
func DeleteUser(userData *Models.User) error {
func DeleteUser(user *Models.User) error {
return DB.Session(&gorm.Session{FullSaveAssociations: true}).
Delete(userData).
Delete(user).
Error
}

+ 9
- 10
Backend/Models/Friends.go View File

@ -1,20 +1,19 @@
package Models
import (
"time"
"database/sql"
"github.com/gofrs/uuid"
)
// Set with Friend being the requestee, and RequestFromID being the requester
// FriendRequest Set with Friend being the requestee, and RequestFromID being the requester
type FriendRequest struct {
Base
UserID uuid.UUID `gorm:"type:uuid;column:user_id;not null;" json:"user_id"`
User User ` json:"user"`
UserUsername string ` json:"user_username"`
FriendID string `gorm:"not null" json:"friend_id"` // Stored encrypted
FriendUsername string ` json:"friend_username"`
FriendPublicAsymmetricKey string ` json:"asymmetric_public_key"`
SymmetricKey string `gorm:"not null" json:"symmetric_key"` // Stored encrypted
AcceptedAt time.Time ` json:"accepted_at"`
UserID uuid.UUID `gorm:"type:uuid;column:user_id;not null;" json:"user_id"`
User User ` json:"user"`
FriendID string `gorm:"not null" json:"friend_id"` // Stored encrypted
FriendUsername string ` json:"friend_username"` // Stored encrypted
FriendPublicAsymmetricKey string ` json:"asymmetric_public_key"` // Stored encrypted
SymmetricKey string `gorm:"not null" json:"symmetric_key"` // Stored encrypted
AcceptedAt sql.NullTime ` json:"accepted_at"`
}

+ 1
- 1
Backend/main.go View File

@ -35,7 +35,7 @@ func main() {
router = mux.NewRouter()
Api.InitApiEndpoints(router)
Api.InitAPIEndpoints(router)
log.Println("Listening on port :8080")
err = http.ListenAndServe(":8080", router)


+ 1
- 0
mobile/analysis_options.yaml View File

@ -24,6 +24,7 @@ linter:
rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
prefer_single_quotes: true
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options

+ 213
- 0
mobile/lib/components/custom_expandable_fab.dart View File

@ -0,0 +1,213 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
class ExpandableFab extends StatefulWidget {
const ExpandableFab({
Key? key,
this.initialOpen,
required this.distance,
required this.icon,
required this.children,
}) : super(key: key);
final bool? initialOpen;
final double distance;
final Icon icon;
final List<Widget> children;
@override
State<ExpandableFab> createState() => _ExpandableFabState();
}
class _ExpandableFabState extends State<ExpandableFab>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
late final Animation<double> _expandAnimation;
bool _open = false;
@override
void initState() {
super.initState();
_open = widget.initialOpen ?? false;
_controller = AnimationController(
value: _open ? 1.0 : 0.0,
duration: const Duration(milliseconds: 250),
vsync: this,
);
_expandAnimation = CurvedAnimation(
curve: Curves.fastOutSlowIn,
reverseCurve: Curves.easeOutQuad,
parent: _controller,
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _toggle() {
setState(() {
_open = !_open;
if (_open) {
_controller.forward();
} else {
_controller.reverse();
}
});
}
@override
Widget build(BuildContext context) {
return SizedBox.expand(
child: Stack(
alignment: Alignment.bottomRight,
clipBehavior: Clip.none,
children: [
_buildTapToCloseFab(),
..._buildExpandingActionButtons(),
_buildTapToOpenFab(),
],
),
);
}
Widget _buildTapToCloseFab() {
return SizedBox(
width: 56.0,
height: 56.0,
child: Center(
child: Material(
shape: const CircleBorder(),
clipBehavior: Clip.antiAlias,
elevation: 4.0,
child: InkWell(
onTap: _toggle,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Icon(
Icons.close,
color: Theme.of(context).primaryColor,
),
),
),
),
),
);
}
List<Widget> _buildExpandingActionButtons() {
final children = <Widget>[];
final count = widget.children.length;
final step = 60.0 / (count - 1);
for (var i = 0, angleInDegrees = 15.0;
i < count;
i++, angleInDegrees += step) {
children.add(
_ExpandingActionButton(
directionInDegrees: angleInDegrees,
maxDistance: widget.distance,
progress: _expandAnimation,
child: widget.children[i],
),
);
}
return children;
}
Widget _buildTapToOpenFab() {
return IgnorePointer(
ignoring: _open,
child: AnimatedContainer(
transformAlignment: Alignment.center,
transform: Matrix4.diagonal3Values(
_open ? 0.7 : 1.0,
_open ? 0.7 : 1.0,
1.0,
),
duration: const Duration(milliseconds: 250),
curve: const Interval(0.0, 0.5, curve: Curves.easeOut),
child: AnimatedOpacity(
opacity: _open ? 0.0 : 1.0,
curve: const Interval(0.25, 1.0, curve: Curves.easeInOut),
duration: const Duration(milliseconds: 250),
child: FloatingActionButton(
onPressed: _toggle,
backgroundColor: Theme.of(context).colorScheme.primary,
child: widget.icon,
),
),
),
);
}
}
@immutable
class _ExpandingActionButton extends StatelessWidget {
const _ExpandingActionButton({
required this.directionInDegrees,
required this.maxDistance,
required this.progress,
required this.child,
});
final double directionInDegrees;
final double maxDistance;
final Animation<double> progress;
final Widget child;
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: progress,
builder: (context, child) {
final offset = Offset.fromDirection(
directionInDegrees * (math.pi / 180.0),
progress.value * maxDistance,
);
return Positioned(
right: 4.0 + offset.dx,
bottom: 4.0 + offset.dy,
child: Transform.rotate(
angle: (1.0 - progress.value) * math.pi / 2,
child: child!,
),
);
},
child: FadeTransition(
opacity: progress,
child: child,
),
);
}
}
class ActionButton extends StatelessWidget {
const ActionButton({
Key? key,
this.onPressed,
required this.icon,
}) : super(key: key);
final VoidCallback? onPressed;
final Widget icon;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Material(
shape: const CircleBorder(),
clipBehavior: Clip.antiAlias,
color: theme.colorScheme.secondary,
elevation: 4.0,
child: IconButton(
onPressed: onPressed,
icon: icon,
color: theme.colorScheme.onSecondary,
),
);
}
}

+ 72
- 0
mobile/lib/components/custom_title_bar.dart View File

@ -0,0 +1,72 @@
import 'package:flutter/material.dart';
@immutable
class CustomTitleBar extends StatelessWidget with PreferredSizeWidget {
const CustomTitleBar({
Key? key,
required this.title,
required this.showBack,
this.rightHandButton,
this.backgroundColor,
}) : super(key: key);
final Text title;
final bool showBack;
final IconButton? rightHandButton;
final Color? backgroundColor;
@override
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
@override
Widget build(BuildContext context) {
return AppBar(
elevation: 0,
automaticallyImplyLeading: false,
backgroundColor:
backgroundColor != null ?
backgroundColor! :
Theme.of(context).appBarTheme.backgroundColor,
flexibleSpace: SafeArea(
child: Container(
padding: const EdgeInsets.only(right: 16),
child: Row(
children: <Widget>[
showBack ?
_backButton(context) :
const SizedBox.shrink(),
showBack ?
const SizedBox(width: 2,) :
const SizedBox(width: 15),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
title,
],
),
),
rightHandButton != null ?
rightHandButton! :
const SizedBox.shrink(),
],
),
),
),
);
}
Widget _backButton(BuildContext context) {
return IconButton(
onPressed: (){
Navigator.pop(context);
},
icon: Icon(
Icons.arrow_back,
color: Theme.of(context).appBarTheme.iconTheme?.color,
),
);
}
}

+ 60
- 0
mobile/lib/components/flash_message.dart View File

@ -0,0 +1,60 @@
import 'package:flutter/material.dart';
class FlashMessage extends StatelessWidget {
const FlashMessage({
Key? key,
required this.message,
}) : super(key: key);
final String message;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Stack(
clipBehavior: Clip.none,
children: <Widget>[
Container(
padding: const EdgeInsets.all(16),
height: 90,
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(20)),
color: theme.colorScheme.onError,
),
child: Column(
children: <Widget>[
Text(
'Error',
style: TextStyle(
color: theme.colorScheme.error,
fontSize: 18
),
),
Text(
message,
style: TextStyle(
color: theme.colorScheme.error,
fontSize: 14
),
),
],
),
),
]
);
}
}
void showMessage(String message, BuildContext context) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: FlashMessage(
message: message,
),
behavior: SnackBarBehavior.floating,
backgroundColor: Colors.transparent,
elevation: 0,
),
);
}

+ 137
- 0
mobile/lib/components/user_search_result.dart View File

@ -0,0 +1,137 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:http/http.dart' as http;
import 'package:pointycastle/impl.dart';
import '/components/custom_circle_avatar.dart';
import '/data_models/user_search.dart';
import '/models/my_profile.dart';
import '/utils/encryption/aes_helper.dart';
import '/utils/storage/session_cookie.dart';
import '/utils/strings.dart';
import '/utils/encryption/crypto_utils.dart';
@immutable
class UserSearchResult extends StatefulWidget {
final UserSearch user;
const UserSearchResult({
Key? key,
required this.user,
}) : super(key: key);
@override
_UserSearchResultState createState() => _UserSearchResultState();
}
class _UserSearchResultState extends State<UserSearchResult>{
bool showFailed = false;
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.only(top: 30),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
CustomCircleAvatar(
initials: widget.user.username[0].toUpperCase(),
icon: const Icon(Icons.person, size: 80),
imagePath: null,
radius: 50,
),
const SizedBox(height: 10),
Text(
widget.user.username,
style: const TextStyle(
fontSize: 35,
),
),
const SizedBox(height: 30),
TextButton(
onPressed: sendFriendRequest,
child: Text(
'Send Friend Request',
style: TextStyle(
color: Theme.of(context).colorScheme.onPrimary,
fontSize: 20,
),
),
style: ButtonStyle(
backgroundColor: MaterialStateProperty.all(Theme.of(context).colorScheme.primary),
padding: MaterialStateProperty.all<EdgeInsets>(
const EdgeInsets.only(left: 20, right: 20, top: 8, bottom: 8)),
),
),
showFailed ? const SizedBox(height: 20) : const SizedBox.shrink(),
failedMessage(context),
],
),
),
);
}
Widget failedMessage(BuildContext context) {
if (!showFailed) {
return const SizedBox.shrink();
}
return Text(
'Failed to send friend request',
style: TextStyle(
color: Theme.of(context).colorScheme.error,
fontSize: 16,
),
);
}
Future<void> sendFriendRequest() async {
MyProfile profile = await MyProfile.getProfile();
String publicKeyString = CryptoUtils.encodeRSAPublicKeyToPem(profile.publicKey!);
RSAPublicKey friendPublicKey = CryptoUtils.rsaPublicKeyFromPem(widget.user.publicKey);
final symmetricKey = AesHelper.deriveKey(generateRandomString(32));
String payloadJson = jsonEncode({
'user_id': widget.user.id,
'friend_id': base64.encode(CryptoUtils.rsaEncrypt(
Uint8List.fromList(profile.id.codeUnits),
friendPublicKey,
)),
'friend_username': base64.encode(CryptoUtils.rsaEncrypt(
Uint8List.fromList(profile.username.codeUnits),
friendPublicKey,
)),
'symmetric_key': base64.encode(CryptoUtils.rsaEncrypt(
Uint8List.fromList(symmetricKey),
friendPublicKey,
)),
'asymmetric_public_key': AesHelper.aesEncrypt(
symmetricKey,
Uint8List.fromList(publicKeyString.codeUnits),
),
});
var resp = await http.post(
Uri.parse('${dotenv.env["SERVER_URL"]}api/v1/auth/friend_request'),
headers: {
'cookie': await getSessionCookie(),
},
body: payloadJson,
);
if (resp.statusCode != 200) {
showFailed = true;
setState(() {});
return;
}
Navigator.pop(context);
}
}

+ 20
- 0
mobile/lib/data_models/user_search.dart View File

@ -0,0 +1,20 @@
class UserSearch {
String id;
String username;
String publicKey;
UserSearch({
required this.id,
required this.username,
required this.publicKey,
});
factory UserSearch.fromJson(Map<String, dynamic> json) {
return UserSearch(
id: json['id'],
username: json['username'],
publicKey: json['asymmetric_public_key'],
);
}
}

+ 4
- 4
mobile/lib/main.dart View File

@ -48,17 +48,17 @@ class MyApp extends StatelessWidget {
darkTheme: ThemeData(
brightness: Brightness.dark,
primaryColor: Colors.orange.shade900,
backgroundColor: Colors.grey.shade900,
backgroundColor: Colors.grey.shade800,
colorScheme: ColorScheme(
brightness: Brightness.dark,
primary: Colors.orange.shade900,
onPrimary: Colors.white,
secondary: Colors.blue.shade400,
secondary: Colors.orange.shade900,
onSecondary: Colors.white,
tertiary: Colors.grey.shade600,
tertiary: Colors.grey.shade500,
onTertiary: Colors.black,
error: Colors.red,
onError: Colors.yellow,
onError: Colors.white,
background: Colors.grey.shade900,
onBackground: Colors.white,
surface: Colors.grey.shade700,


+ 76
- 60
mobile/lib/models/friends.dart View File

@ -1,7 +1,9 @@
import 'dart:convert';
import 'dart:typed_data';
import "package:pointycastle/export.dart";
import '../utils/encryption/aes_helper.dart';
import 'package:pointycastle/export.dart';
import '/utils/encryption/aes_helper.dart';
import '/utils/encryption/crypto_utils.dart';
import '/utils/storage/database.dart';
@ -12,7 +14,63 @@ Friend findFriendByFriendId(List<Friend> friends, String id) {
}
}
// Or return `null`.
throw ArgumentError.value(id, "id", "No element with that id");
throw ArgumentError.value(id, 'id', 'No element with that id');
}
Future<Friend> getFriendByFriendId(String userId) async {
final db = await getDatabaseConnection();
final List<Map<String, dynamic>> maps = await db.query(
'friends',
where: 'friend_id = ?',
whereArgs: [userId],
);
if (maps.length != 1) {
throw ArgumentError('Invalid user id');
}
return Friend(
id: maps[0]['id'],
userId: maps[0]['user_id'],
friendId: maps[0]['friend_id'],
friendSymmetricKey: maps[0]['symmetric_key'],
publicKey: CryptoUtils.rsaPublicKeyFromPem(maps[0]['asymmetric_public_key']),
acceptedAt: maps[0]['accepted_at'] != null ? DateTime.parse(maps[0]['accepted_at']) : null,
username: maps[0]['username'],
);
}
Future<List<Friend>> getFriends({bool? accepted}) async {
final db = await getDatabaseConnection();
String? where;
if (accepted == true) {
where = 'accepted_at IS NOT NULL';
}
if (accepted == false) {
where = 'accepted_at IS NULL';
}
final List<Map<String, dynamic>> maps = await db.query(
'friends',
where: where,
);
return List.generate(maps.length, (i) {
return Friend(
id: maps[i]['id'],
userId: maps[i]['user_id'],
friendId: maps[i]['friend_id'],
friendSymmetricKey: maps[i]['symmetric_key'],
publicKey: CryptoUtils.rsaPublicKeyFromPem(maps[i]['asymmetric_public_key']),
acceptedAt: maps[i]['accepted_at'] != null ? DateTime.parse(maps[i]['accepted_at']) : null,
username: maps[i]['username'],
);
});
}
class Friend{
@ -22,7 +80,7 @@ class Friend{
String friendId;
String friendSymmetricKey;
RSAPublicKey publicKey;
String acceptedAt;
DateTime? acceptedAt;
bool? selected;
Friend({
required this.id,
@ -65,20 +123,14 @@ class Friend{
friendId: String.fromCharCodes(idDecrypted),
friendSymmetricKey: base64.encode(symmetricKeyDecrypted),
publicKey: publicKey,
acceptedAt: json['accepted_at'],
acceptedAt: json['accepted_at']['Valid'] ?
DateTime.parse(json['accepted_at']['Time']) :
null,
);
}
@override
String toString() {
return '''
id: $id
userId: $userId
username: $username
friendId: $friendId
accepted_at: $acceptedAt''';
String publicKeyPem() {
return CryptoUtils.encodeRSAPublicKeyToPem(publicKey);
}
Map<String, dynamic> toMap() {
@ -89,55 +141,19 @@ class Friend{
'friend_id': friendId,
'symmetric_key': friendSymmetricKey,
'asymmetric_public_key': publicKeyPem(),
'accepted_at': acceptedAt,
'accepted_at': acceptedAt?.toIso8601String(),
};
}
String publicKeyPem() {
return CryptoUtils.encodeRSAPublicKeyToPem(publicKey);
}
}
// A method that retrieves all the dogs from the dogs table.
Future<List<Friend>> getFriends() async {
final db = await getDatabaseConnection();
final List<Map<String, dynamic>> maps = await db.query('friends');
return List.generate(maps.length, (i) {
return Friend(
id: maps[i]['id'],
userId: maps[i]['user_id'],
friendId: maps[i]['friend_id'],
friendSymmetricKey: maps[i]['symmetric_key'],
publicKey: CryptoUtils.rsaPublicKeyFromPem(maps[i]['asymmetric_public_key']),
acceptedAt: maps[i]['accepted_at'],
username: maps[i]['username'],
);
});
}
Future<Friend> getFriendByFriendId(String userId) async {
final db = await getDatabaseConnection();
@override
String toString() {
return '''
final List<Map<String, dynamic>> maps = await db.query(
'friends',
where: 'friend_id = ?',
whereArgs: [userId],
);
if (maps.length != 1) {
throw ArgumentError('Invalid user id');
id: $id
userId: $userId
username: $username
friendId: $friendId
accepted_at: $acceptedAt''';
}
return Friend(
id: maps[0]['id'],
userId: maps[0]['user_id'],
friendId: maps[0]['friend_id'],
friendSymmetricKey: maps[0]['symmetric_key'],
publicKey: CryptoUtils.rsaPublicKeyFromPem(maps[0]['asymmetric_public_key']),
acceptedAt: maps[0]['accepted_at'],
username: maps[0]['username'],
);
}

+ 25
- 23
mobile/lib/utils/storage/conversations.dart View File

@ -1,14 +1,34 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:http/http.dart' as http;
import 'package:pointycastle/export.dart';
import 'package:sqflite/sqflite.dart';
import '/models/my_profile.dart';
import '/models/conversations.dart';
import '/models/conversation_users.dart';
import '/models/conversations.dart';
import '/models/my_profile.dart';
import '/utils/encryption/aes_helper.dart';
import '/utils/storage/database.dart';
import '/utils/storage/session_cookie.dart';
import '/utils/encryption/aes_helper.dart';
Future<void> updateConversation(Conversation conversation, { includeUsers = true } ) async {
String sessionCookie = await getSessionCookie();
Map<String, dynamic> conversationJson = await conversation.payloadJson(includeUsers: includeUsers);
var x = await http.put(
Uri.parse('${dotenv.env["SERVER_URL"]}api/v1/auth/conversations'),
headers: <String, String>{
'Content-Type': 'application/json; charset=UTF-8',
'cookie': sessionCookie,
},
body: jsonEncode(conversationJson),
);
// TODO: Handle errors here
print(x.statusCode);
}
// TODO: Refactor this function
Future<void> updateConversations() async {
@ -98,6 +118,7 @@ Future<void> updateConversations() async {
// }
}
Future<void> uploadConversation(Conversation conversation) async {
String sessionCookie = await getSessionCookie();
@ -116,22 +137,3 @@ Future<void> uploadConversation(Conversation conversation) async {
print(x.statusCode);
}
Future<void> updateConversation(Conversation conversation, { includeUsers = true } ) async {
String sessionCookie = await getSessionCookie();
Map<String, dynamic> conversationJson = await conversation.payloadJson(includeUsers: includeUsers);
var x = await http.put(
Uri.parse('${dotenv.env["SERVER_URL"]}api/v1/auth/conversations'),
headers: <String, String>{
'Content-Type': 'application/json; charset=UTF-8',
'cookie': sessionCookie,
},
body: jsonEncode(conversationJson),
);
// TODO: Handle errors here
print(x.statusCode);
}

+ 4
- 5
mobile/lib/utils/storage/friends.dart View File

@ -13,7 +13,7 @@ import '/utils/storage/session_cookie.dart';
Future<void> updateFriends() async {
RSAPrivateKey privKey = await MyProfile.getPrivateKey();
try {
// try {
var resp = await http.get(
Uri.parse('${dotenv.env["SERVER_URL"]}api/v1/auth/friend_requests'),
headers: {
@ -42,9 +42,8 @@ Future<void> updateFriends() async {
);
}
} catch (SocketException) {
return;
}
// } catch (SocketException) {
// return;
// }
}

+ 22
- 19
mobile/lib/views/authentication/signup.dart View File

@ -1,27 +1,13 @@
import 'dart:typed_data';
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:http/http.dart' as http;
import '/utils/encryption/aes_helper.dart';
import '/utils/encryption/crypto_utils.dart';
class SignupResponse {
final String status;
final String message;
const SignupResponse({
required this.status,
required this.message,
});
factory SignupResponse.fromJson(Map<String, dynamic> json) {
return SignupResponse(
status: json['status'],
message: json['message'],
);
}
}
Future<SignupResponse> signUp(context, String username, String password, String confirmPassword) async {
var keyPair = CryptoUtils.generateRSAKeyPair();
@ -32,7 +18,7 @@ Future<SignupResponse> signUp(context, String username, String password, String
// TODO: Check for timeout here
final resp = await http.post(
Uri.parse('http://192.168.1.5:8080/api/v1/signup'),
Uri.parse('${dotenv.env["SERVER_URL"]}api/v1/signup'),
headers: <String, String>{
'Content-Type': 'application/json; charset=UTF-8',
},
@ -80,6 +66,23 @@ class Signup extends StatelessWidget {
}
}
class SignupResponse {
final String status;
final String message;
const SignupResponse({
required this.status,
required this.message,
});
factory SignupResponse.fromJson(Map<String, dynamic> json) {
return SignupResponse(
status: json['status'],
message: json['message'],
);
}
}
class SignupWidget extends StatefulWidget {
const SignupWidget({Key? key}) : super(key: key);


+ 12
- 39
mobile/lib/views/main/conversation/detail.dart View File

@ -1,3 +1,4 @@
import 'package:Envelope/components/custom_title_bar.dart';
import 'package:flutter/material.dart';
import '/models/conversations.dart';
@ -28,41 +29,17 @@ class _ConversationDetailState extends State<ConversationDetail> {
@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: Icon(
Icons.arrow_back,
color: Theme.of(context).appBarTheme.iconTheme?.color,
),
),
const SizedBox(width: 2,),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(
widget.conversation.name,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Theme.of(context).appBarTheme.toolbarTextStyle?.color
),
),
],
),
),
IconButton(
appBar: CustomTitleBar(
title: Text(
widget.conversation.name,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Theme.of(context).appBarTheme.toolbarTextStyle?.color
),
),
showBack: true,
rightHandButton: IconButton(
onPressed: (){
Navigator.of(context).push(
MaterialPageRoute(builder: (context) => ConversationSettings(conversation: widget.conversation)),
@ -73,10 +50,6 @@ class _ConversationDetailState extends State<ConversationDetail> {
color: Theme.of(context).appBarTheme.iconTheme?.color,
),
),
],
),
),
),
),
body: Stack(
children: <Widget>[


+ 63
- 73
mobile/lib/views/main/conversation/list.dart View File

@ -1,3 +1,4 @@
import 'package:Envelope/components/custom_title_bar.dart';
import 'package:Envelope/models/friends.dart';
import 'package:Envelope/utils/storage/conversations.dart';
import 'package:flutter/material.dart';
@ -28,83 +29,72 @@ class _ConversationListState extends State<ConversationList> {
@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: const <Widget>[
Text(
'Conversations',
style: TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold
)
),
],
),
),
),
Padding(
padding: const EdgeInsets.only(top: 16,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: 16,left: 16,right: 16),
child: list(),
),
],
),
appBar: const CustomTitleBar(
title: Text(
'Conversations',
style: TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold
)
),
showBack: false,
backgroundColor: Colors.transparent,
),
floatingActionButton: Padding(
padding: const EdgeInsets.only(right: 10, bottom: 10),
child: FloatingActionButton(
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (context) => ConversationEditDetails(
saveCallback: (String conversationName) {
Navigator.of(context).push(
MaterialPageRoute(builder: (context) => ConversationAddFriendsList(
friends: friends,
saveCallback: (List<Friend> friendsSelected) async {
Conversation conversation = await createConversation(
conversationName,
friendsSelected
);
body: Padding(
padding: const EdgeInsets.only(top: 16,left: 16,right: 16),
child: SingleChildScrollView(
child: Column(
children: <Widget>[
TextField(
decoration: const InputDecoration(
hintText: "Search...",
prefixIcon: Icon(
Icons.search,
size: 20
),
),
onChanged: (value) => filterSearchResults(value.toLowerCase())
),
list(),
],
),
),
),
floatingActionButton: Padding(
padding: const EdgeInsets.only(right: 10, bottom: 10),
child: FloatingActionButton(
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (context) => ConversationEditDetails(
saveCallback: (String conversationName) {
Navigator.of(context).push(
MaterialPageRoute(builder: (context) => ConversationAddFriendsList(
friends: friends,
saveCallback: (List<Friend> friendsSelected) async {
Conversation conversation = await createConversation(
conversationName,
friendsSelected
);
uploadConversation(conversation);
uploadConversation(conversation);
Navigator.of(context).popUntil((route) => route.isFirst);
Navigator.push(context, MaterialPageRoute(builder: (context){
return ConversationDetail(
conversation: conversation,
);
}));
},
))
);
},
)),
).then(onGoBack);
},
backgroundColor: Theme.of(context).colorScheme.primary,
child: const Icon(Icons.add, size: 30),
),
Navigator.of(context).popUntil((route) => route.isFirst);
Navigator.push(context, MaterialPageRoute(builder: (context){
return ConversationDetail(
conversation: conversation,
);
}));
},
))
);
},
)),
).then(onGoBack);
},
backgroundColor: Theme.of(context).colorScheme.primary,
child: const Icon(Icons.add, size: 30),
),
),
);
}


+ 9
- 32
mobile/lib/views/main/conversation/settings.dart View File

@ -1,3 +1,4 @@
import 'package:Envelope/components/custom_title_bar.dart';
import 'package:Envelope/models/friends.dart';
import 'package:Envelope/utils/encryption/crypto_utils.dart';
import 'package:Envelope/views/main/conversation/create_add_users.dart';
@ -32,40 +33,16 @@ class _ConversationSettingsState extends State<ConversationSettings> {
@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(
widget.conversation.name + " Settings",
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600
),
),
],
),
),
],
),
appBar: CustomTitleBar(
title: Text(
widget.conversation.name + " Settings",
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Theme.of(context).appBarTheme.toolbarTextStyle?.color
),
),
showBack: true,
),
body: Padding(
padding: const EdgeInsets.all(15),


+ 151
- 0
mobile/lib/views/main/friend/add_search.dart View File

@ -0,0 +1,151 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:flutter_dotenv/flutter_dotenv.dart';
import '/utils/storage/session_cookie.dart';
import '/components/user_search_result.dart';
import '/data_models/user_search.dart';
class FriendAddSearch extends StatefulWidget {
const FriendAddSearch({
Key? key,
}) : super(key: key);
@override
State<FriendAddSearch> createState() => _FriendAddSearchState();
}
class _FriendAddSearchState extends State<FriendAddSearch> {
UserSearch? user;
Text centerMessage = const Text('Search to add friends...');
TextEditingController searchController = TextEditingController();
@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: Icon(
Icons.arrow_back,
color: Theme.of(context).appBarTheme.iconTheme?.color,
),
),
const SizedBox(width: 2),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(
'Add Friends',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Theme.of(context).appBarTheme.toolbarTextStyle?.color
)
),
],
)
)
]
),
),
),
),
body: Stack(
children: <Widget>[
Padding(
padding: const EdgeInsets.only(top: 16,left: 16,right: 16),
child: TextField(
autofocus: true,
decoration: InputDecoration(
hintText: 'Search...',
prefixIcon: const Icon(
Icons.search,
size: 20
),
suffixIcon: Padding(
padding: const EdgeInsets.only(top: 4, bottom: 4, right: 8),
child: OutlinedButton(
style: ButtonStyle(
backgroundColor: MaterialStateProperty.all(Theme.of(context).colorScheme.secondary),
foregroundColor: MaterialStateProperty.all(Theme.of(context).colorScheme.onSecondary),
shape: MaterialStateProperty.all(RoundedRectangleBorder(borderRadius: BorderRadius.circular(16.0))),
elevation: MaterialStateProperty.all(4),
),
onPressed: searchUsername,
child: const Icon(Icons.search, size: 25),
),
),
),
controller: searchController,
),
),
Padding(
padding: const EdgeInsets.only(top: 90),
child: showFriend(),
),
],
),
);
}
Widget showFriend() {
if (user == null) {
return Center(
child: centerMessage,
);
}
return UserSearchResult(
user: user!,
);
}
Future<void> searchUsername() async {
if (searchController.text.isEmpty) {
return;
}
Map<String, String> params = {};
params['username'] = searchController.text;
var uri = Uri.parse('${dotenv.env["SERVER_URL"]}api/v1/auth/users');
uri = uri.replace(queryParameters: params);
var resp = await http.get(
uri,
headers: {
'cookie': await getSessionCookie(),
}
);
if (resp.statusCode != 200) {
user = null;
centerMessage = const Text('User not found');
setState(() {});
return;
}
user = UserSearch.fromJson(
jsonDecode(resp.body)
);
setState(() {});
FocusScope.of(context).unfocus();
searchController.clear();
}
}

+ 109
- 66
mobile/lib/views/main/friend/list.dart View File

@ -1,13 +1,15 @@
import 'package:Envelope/components/custom_title_bar.dart';
import 'package:Envelope/views/main/friend/add_search.dart';
import 'package:Envelope/views/main/friend/request_list_item.dart';
import 'package:flutter/material.dart';
import '/models/friends.dart';
import '/components/custom_expandable_fab.dart';
import '/views/main/friend/list_item.dart';
class FriendList extends StatefulWidget {
final List<Friend> friends;
const FriendList({
Key? key,
required this.friends,
}) : super(key: key);
@override
@ -16,86 +18,83 @@ class FriendList extends StatefulWidget {
class _FriendListState extends State<FriendList> {
List<Friend> friends = [];
List<Friend> friendRequests = [];
List<Friend> friendsDuplicate = [];
List<Friend> friendRequestsDuplicate = [];
@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("Friends",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(20),
color: Theme.of(context).colorScheme.tertiary
),
child: Row(
children: <Widget>[
Icon(
Icons.add,
color: Theme.of(context).primaryColor,
size: 20
),
const SizedBox(width: 2,),
const Text(
"Add",
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold
)
),
],
),
)
],
),
),
),
Padding(
padding: const EdgeInsets.only(top: 16,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: 16,left: 16,right: 16),
child: list(),
),
appBar: const CustomTitleBar(
title: Text(
'Friends',
style: TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold
)
),
showBack: false,
backgroundColor: Colors.transparent,
),
body: Padding(
padding: const EdgeInsets.only(top: 16,left: 16,right: 16),
child: SingleChildScrollView(
child: Column(
children: <Widget>[
TextField(
decoration: const InputDecoration(
hintText: 'Search...',
prefixIcon: Icon(
Icons.search,
size: 20
),
),
onChanged: (value) => filterSearchResults(value.toLowerCase())
),
headingOrNull('Friend Requests'),
friendRequestList(),
headingOrNull('Friends'),
friendList(),
],
),
),
),
floatingActionButton: Padding(
padding: const EdgeInsets.only(right: 10, bottom: 10),
child: ExpandableFab(
icon: const Icon(Icons.add, size: 30),
distance: 90.0,
children: [
ActionButton(
onPressed: () {},
icon: const Icon(Icons.qr_code_2, size: 25),
),
ActionButton(
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (context) => const FriendAddSearch())
);//.then(onGoBack); // TODO
},
icon: const Icon(Icons.search, size: 25),
),
],
)
)
);
}
void filterSearchResults(String query) {
List<Friend> dummySearchList = [];
dummySearchList.addAll(widget.friends);
dummySearchList.addAll(friends);
if(query.isNotEmpty) {
List<Friend> dummyListData = [];
dummySearchList.forEach((item) {
for (Friend item in dummySearchList) {
if(item.username.toLowerCase().contains(query)) {
dummyListData.add(item);
}
});
}
setState(() {
friends.clear();
friends.addAll(dummyListData);
@ -105,18 +104,62 @@ class _FriendListState extends State<FriendList> {
setState(() {
friends.clear();
friends.addAll(widget.friends);
friends.addAll(friends);
});
}
@override
void initState() {
super.initState();
friends.addAll(widget.friends);
initFriends();
}
Future<void> initFriends() async {
friends = await getFriends(accepted: true);
friendRequests = await getFriends(accepted: false);
setState(() {});
}
Widget list() {
Widget headingOrNull(String heading) {
if (friends.isEmpty || friendRequests.isEmpty) {
return const SizedBox.shrink();
}
return Padding(
padding: const EdgeInsets.only(top: 16),
child: Align(
alignment: Alignment.centerLeft,
child: Text(
heading,
style: TextStyle(
fontSize: 16,
color: Theme.of(context).colorScheme.tertiary,
),
),
)
);
}
Widget friendRequestList() {
if (friendRequests.isEmpty) {
return const SizedBox.shrink();
}
return ListView.builder(
itemCount: friendRequests.length,
shrinkWrap: true,
padding: const EdgeInsets.only(top: 16),
physics: const NeverScrollableScrollPhysics(),
itemBuilder: (context, i) {
return FriendRequestListItem(
friend: friendRequests[i],
callback: initFriends,
);
},
);
}
Widget friendList() {
if (friends.isEmpty) {
return const Center(
child: Text('No Friends'),


+ 32
- 32
mobile/lib/views/main/friend/list_item.dart View File

@ -18,40 +18,40 @@ class _FriendListItemState extends State<FriendListItem> {
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () async {
},
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)),
],
),
),
),
),
],
behavior: HitTestBehavior.opaque,
onTap: () async {
},
child: Container(
padding: const EdgeInsets.only(left: 16,right: 16,top: 0,bottom: 20),
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)),
],
),
),
),
),
],
),
],
),
),
],
),
),
);
}


+ 180
- 0
mobile/lib/views/main/friend/request_list_item.dart View File

@ -0,0 +1,180 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:Envelope/components/flash_message.dart';
import 'package:Envelope/utils/storage/database.dart';
import 'package:flutter/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:http/http.dart' as http;
import '/components/custom_circle_avatar.dart';
import '/models/friends.dart';
import '/utils/storage/session_cookie.dart';
import '/models/my_profile.dart';
import '/utils/encryption/aes_helper.dart';
import '/utils/encryption/crypto_utils.dart';
import '/utils/strings.dart';
class FriendRequestListItem extends StatefulWidget{
final Friend friend;
final Function callback;
const FriendRequestListItem({
Key? key,
required this.friend,
required this.callback,
}) : super(key: key);
@override
_FriendRequestListItemState createState() => _FriendRequestListItemState();
}
class _FriendRequestListItemState extends State<FriendRequestListItem> {
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () async {
},
child: Container(
padding: const EdgeInsets.only(left: 16,right: 10,top: 0,bottom: 20),
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)),
],
),
),
),
),
SizedBox(
height: 30,
width: 30,
child: IconButton(
onPressed: () { acceptFriendRequest(context); },
icon: const Icon(Icons.check),
padding: const EdgeInsets.all(0),
splashRadius: 20,
),
),
const SizedBox(width: 6),
SizedBox(
height: 30,
width: 30,
child: IconButton(
onPressed: rejectFriendRequest,
icon: const Icon(Icons.cancel),
padding: const EdgeInsets.all(0),
splashRadius: 20,
),
),
],
),
),
],
),
),
);
}
Future<void> acceptFriendRequest(BuildContext context) async {
MyProfile profile = await MyProfile.getProfile();
String publicKeyString = CryptoUtils.encodeRSAPublicKeyToPem(profile.publicKey!);
final symmetricKey = AesHelper.deriveKey(generateRandomString(32));
String payloadJson = jsonEncode({
'user_id': widget.friend.userId,
'friend_id': base64.encode(CryptoUtils.rsaEncrypt(
Uint8List.fromList(profile.id.codeUnits),
widget.friend.publicKey,
)),
'friend_username': base64.encode(CryptoUtils.rsaEncrypt(
Uint8List.fromList(profile.username.codeUnits),
widget.friend.publicKey,
)),
'symmetric_key': base64.encode(CryptoUtils.rsaEncrypt(
Uint8List.fromList(symmetricKey),
widget.friend.publicKey,
)),
'asymmetric_public_key': AesHelper.aesEncrypt(
symmetricKey,
Uint8List.fromList(publicKeyString.codeUnits),
),
});
var resp = await http.post(
Uri.parse('${dotenv.env["SERVER_URL"]}api/v1/auth/friend_request/${widget.friend.id}'),
headers: {
'cookie': await getSessionCookie(),
},
body: payloadJson,
);
if (resp.statusCode != 204) {
showMessage(
'Failed to accept friend request, please try again later',
context
);
return;
}
final db = await getDatabaseConnection();
widget.friend.acceptedAt = DateTime.now();
await db.update(
'friends',
widget.friend.toMap(),
where: 'id = ?',
whereArgs: [widget.friend.id],
);
widget.callback();
}
Future<void> rejectFriendRequest() async {
var resp = await http.delete(
Uri.parse('${dotenv.env["SERVER_URL"]}api/v1/auth/friend_request/${widget.friend.id}'),
headers: {
'cookie': await getSessionCookie(),
},
);
if (resp.statusCode != 204) {
showMessage(
'Failed to decline friend request, please try again later',
context
);
return;
}
final db = await getDatabaseConnection();
await db.delete(
'friends',
where: 'id = ?',
whereArgs: [widget.friend.id],
);
widget.callback();
}
}

+ 5
- 4
mobile/lib/views/main/home.dart View File

@ -23,6 +23,7 @@ class Home extends StatefulWidget {
class _HomeState extends State<Home> {
List<Conversation> conversations = [];
List<Friend> friends = [];
List<Friend> friendRequests = [];
MyProfile profile = MyProfile(
id: '',
username: '',
@ -32,7 +33,7 @@ class _HomeState extends State<Home> {
int _selectedIndex = 0;
List<Widget> _widgetOptions = <Widget>[
const ConversationList(conversations: [], friends: []),
const FriendList(friends: []),
const FriendList(),
Profile(
profile: MyProfile(
id: '',
@ -134,7 +135,7 @@ class _HomeState extends State<Home> {
children: const <Widget> [
CircularProgressIndicator(),
SizedBox(height: 25),
Text("Loading..."),
Text('Loading...'),
],
)
),
@ -152,7 +153,7 @@ class _HomeState extends State<Home> {
await updateMessageThreads();
conversations = await getConversations();
friends = await getFriends();
friends = await getFriends(accepted: true);
profile = await MyProfile.getProfile();
setState(() {
@ -161,7 +162,7 @@ class _HomeState extends State<Home> {
conversations: conversations,
friends: friends,
),
FriendList(friends: friends),
const FriendList(),
Profile(profile: profile),
];
isLoading = false;


+ 27
- 41
mobile/lib/views/main/profile/profile.dart View File

@ -1,3 +1,4 @@
import 'package:Envelope/components/custom_title_bar.dart';
import 'package:flutter/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:qr_flutter/qr_flutter.dart';
@ -133,46 +134,31 @@ class _ProfileState extends State<Profile> {
@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: const <Widget>[
Text(
'Profile',
style: TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
),
),
],
),
),
),
Padding(
padding: const EdgeInsets.only(top: 16,left: 16,right: 16),
child: Column(
children: <Widget>[
const SizedBox(height: 30),
usernameHeading(),
const SizedBox(height: 30),
_profileQrCode(),
const SizedBox(height: 30),
settings(),
const SizedBox(height: 30),
logout(),
],
)
),
],
),
appBar: const CustomTitleBar(
title: Text(
'Profile',
style: TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold
)
),
);
}
showBack: false,
backgroundColor: Colors.transparent,
),
body: Padding(
padding: const EdgeInsets.only(top: 16,left: 16,right: 16),
child: Column(
children: <Widget>[
usernameHeading(),
const SizedBox(height: 30),
_profileQrCode(),
const SizedBox(height: 30),
settings(),
const SizedBox(height: 30),
logout(),
],
)
),
);
}
}

Loading…
Cancel
Save