From 1f4d26165fac9ff86722114ab5133084c4475660 Mon Sep 17 00:00:00 2001 From: Tovi Jaeschke-Rogers Date: Thu, 11 Aug 2022 19:23:58 +0930 Subject: [PATCH] Accept and reject friend requests Added custom error message for when interactions fail --- Backend/Api/Friends/AcceptFriendRequest.go | 75 ++++++ Backend/Api/Friends/EncryptedFriendsList.go | 9 +- Backend/Api/Friends/Friends.go | 33 +-- Backend/Api/Friends/RejectFriendRequest.go | 44 ++++ Backend/Api/Messages/Conversations.go | 14 +- Backend/Api/Messages/MessageThread.go | 9 +- Backend/Api/Routes.go | 34 +-- Backend/Api/Users/SearchUsers.go | 56 +++++ Backend/Database/FriendRequests.go | 24 +- Backend/Database/Seeder/FriendSeeder.go | 35 ++- Backend/Database/Users.go | 40 ++-- Backend/Models/Friends.go | 19 +- Backend/main.go | 2 +- mobile/analysis_options.yaml | 1 + .../lib/components/custom_expandable_fab.dart | 213 ++++++++++++++++++ mobile/lib/components/custom_title_bar.dart | 72 ++++++ mobile/lib/components/flash_message.dart | 60 +++++ mobile/lib/components/user_search_result.dart | 137 +++++++++++ mobile/lib/data_models/user_search.dart | 20 ++ mobile/lib/main.dart | 8 +- mobile/lib/models/friends.dart | 136 ++++++----- mobile/lib/utils/storage/conversations.dart | 48 ++-- mobile/lib/utils/storage/friends.dart | 9 +- mobile/lib/views/authentication/signup.dart | 41 ++-- .../lib/views/main/conversation/detail.dart | 51 +---- mobile/lib/views/main/conversation/list.dart | 136 ++++++----- .../lib/views/main/conversation/settings.dart | 41 +--- mobile/lib/views/main/friend/add_search.dart | 151 +++++++++++++ mobile/lib/views/main/friend/list.dart | 175 ++++++++------ mobile/lib/views/main/friend/list_item.dart | 64 +++--- .../views/main/friend/request_list_item.dart | 180 +++++++++++++++ mobile/lib/views/main/home.dart | 9 +- mobile/lib/views/main/profile/profile.dart | 68 +++--- 33 files changed, 1509 insertions(+), 505 deletions(-) create mode 100644 Backend/Api/Friends/AcceptFriendRequest.go create mode 100644 Backend/Api/Friends/RejectFriendRequest.go create mode 100644 Backend/Api/Users/SearchUsers.go create mode 100644 mobile/lib/components/custom_expandable_fab.dart create mode 100644 mobile/lib/components/custom_title_bar.dart create mode 100644 mobile/lib/components/flash_message.dart create mode 100644 mobile/lib/components/user_search_result.dart create mode 100644 mobile/lib/data_models/user_search.dart create mode 100644 mobile/lib/views/main/friend/add_search.dart create mode 100644 mobile/lib/views/main/friend/request_list_item.dart diff --git a/Backend/Api/Friends/AcceptFriendRequest.go b/Backend/Api/Friends/AcceptFriendRequest.go new file mode 100644 index 0000000..8ba80d1 --- /dev/null +++ b/Backend/Api/Friends/AcceptFriendRequest.go @@ -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) +} diff --git a/Backend/Api/Friends/EncryptedFriendsList.go b/Backend/Api/Friends/EncryptedFriendsList.go index 441284f..410c75c 100644 --- a/Backend/Api/Friends/EncryptedFriendsList.go +++ b/Backend/Api/Friends/EncryptedFriendsList.go @@ -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) } diff --git a/Backend/Api/Friends/Friends.go b/Backend/Api/Friends/Friends.go index 050b4d8..07316af 100644 --- a/Backend/Api/Friends/Friends.go +++ b/Backend/Api/Friends/Friends.go @@ -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) } diff --git a/Backend/Api/Friends/RejectFriendRequest.go b/Backend/Api/Friends/RejectFriendRequest.go new file mode 100644 index 0000000..b1b2c87 --- /dev/null +++ b/Backend/Api/Friends/RejectFriendRequest.go @@ -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) +} diff --git a/Backend/Api/Messages/Conversations.go b/Backend/Api/Messages/Conversations.go index 4475790..27d1470 100644 --- a/Backend/Api/Messages/Conversations.go +++ b/Backend/Api/Messages/Conversations.go @@ -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) } diff --git a/Backend/Api/Messages/MessageThread.go b/Backend/Api/Messages/MessageThread.go index b9c4ee7..14fac7c 100644 --- a/Backend/Api/Messages/MessageThread.go +++ b/Backend/Api/Messages/MessageThread.go @@ -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) } diff --git a/Backend/Api/Routes.go b/Backend/Api/Routes.go index e1f8df4..0143f90 100644 --- a/Backend/Api/Routes.go +++ b/Backend/Api/Routes.go @@ -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") } diff --git a/Backend/Api/Users/SearchUsers.go b/Backend/Api/Users/SearchUsers.go new file mode 100644 index 0000000..56ecd89 --- /dev/null +++ b/Backend/Api/Users/SearchUsers.go @@ -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) +} diff --git a/Backend/Database/FriendRequests.go b/Backend/Database/FriendRequests.go index dec6860..f6393d5 100644 --- a/Backend/Database/FriendRequests.go +++ b/Backend/Database/FriendRequests.go @@ -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 } diff --git a/Backend/Database/Seeder/FriendSeeder.go b/Backend/Database/Seeder/FriendSeeder.go index 7b3f960..f3b5203 100644 --- a/Backend/Database/Seeder/FriendSeeder.go +++ b/Backend/Database/Seeder/FriendSeeder.go @@ -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) + } + } } } diff --git a/Backend/Database/Users.go b/Backend/Database/Users.go index 22e3f90..2df6a73 100644 --- a/Backend/Database/Users.go +++ b/Backend/Database/Users.go @@ -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 } diff --git a/Backend/Models/Friends.go b/Backend/Models/Friends.go index 183e2dc..967af7d 100644 --- a/Backend/Models/Friends.go +++ b/Backend/Models/Friends.go @@ -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"` } diff --git a/Backend/main.go b/Backend/main.go index 634cac0..e9dc701 100644 --- a/Backend/main.go +++ b/Backend/main.go @@ -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) diff --git a/mobile/analysis_options.yaml b/mobile/analysis_options.yaml index 61b6c4d..f630962 100644 --- a/mobile/analysis_options.yaml +++ b/mobile/analysis_options.yaml @@ -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 diff --git a/mobile/lib/components/custom_expandable_fab.dart b/mobile/lib/components/custom_expandable_fab.dart new file mode 100644 index 0000000..7b27e3c --- /dev/null +++ b/mobile/lib/components/custom_expandable_fab.dart @@ -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 children; + + @override + State createState() => _ExpandableFabState(); +} + +class _ExpandableFabState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + late final Animation _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 _buildExpandingActionButtons() { + final children = []; + 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 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, + ), + ); + } +} diff --git a/mobile/lib/components/custom_title_bar.dart b/mobile/lib/components/custom_title_bar.dart new file mode 100644 index 0000000..527b1d2 --- /dev/null +++ b/mobile/lib/components/custom_title_bar.dart @@ -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: [ + 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: [ + 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, + ), + ); + } +} + diff --git a/mobile/lib/components/flash_message.dart b/mobile/lib/components/flash_message.dart new file mode 100644 index 0000000..df5eb8f --- /dev/null +++ b/mobile/lib/components/flash_message.dart @@ -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: [ + Container( + padding: const EdgeInsets.all(16), + height: 90, + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(20)), + color: theme.colorScheme.onError, + ), + child: Column( + children: [ + 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, + ), + ); +} diff --git a/mobile/lib/components/user_search_result.dart b/mobile/lib/components/user_search_result.dart new file mode 100644 index 0000000..4b0155d --- /dev/null +++ b/mobile/lib/components/user_search_result.dart @@ -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{ + bool showFailed = false; + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.only(top: 30), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + 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( + 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 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); + } +} diff --git a/mobile/lib/data_models/user_search.dart b/mobile/lib/data_models/user_search.dart new file mode 100644 index 0000000..6be8501 --- /dev/null +++ b/mobile/lib/data_models/user_search.dart @@ -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 json) { + return UserSearch( + id: json['id'], + username: json['username'], + publicKey: json['asymmetric_public_key'], + ); + } +} diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 7890c86..656c188 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -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, diff --git a/mobile/lib/models/friends.dart b/mobile/lib/models/friends.dart index 5711e3a..269a8ad 100644 --- a/mobile/lib/models/friends.dart +++ b/mobile/lib/models/friends.dart @@ -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 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 getFriendByFriendId(String userId) async { + final db = await getDatabaseConnection(); + + final List> 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> 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> 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 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> getFriends() async { - final db = await getDatabaseConnection(); - - final List> 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 getFriendByFriendId(String userId) async { - final db = await getDatabaseConnection(); + @override + String toString() { + return ''' - final List> 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'], - ); } diff --git a/mobile/lib/utils/storage/conversations.dart b/mobile/lib/utils/storage/conversations.dart index b5aa0b9..d5068d4 100644 --- a/mobile/lib/utils/storage/conversations.dart +++ b/mobile/lib/utils/storage/conversations.dart @@ -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 updateConversation(Conversation conversation, { includeUsers = true } ) async { + String sessionCookie = await getSessionCookie(); + + Map conversationJson = await conversation.payloadJson(includeUsers: includeUsers); + + var x = await http.put( + Uri.parse('${dotenv.env["SERVER_URL"]}api/v1/auth/conversations'), + headers: { + 'Content-Type': 'application/json; charset=UTF-8', + 'cookie': sessionCookie, + }, + body: jsonEncode(conversationJson), + ); + + // TODO: Handle errors here + print(x.statusCode); +} // TODO: Refactor this function Future updateConversations() async { @@ -98,6 +118,7 @@ Future updateConversations() async { // } } + Future uploadConversation(Conversation conversation) async { String sessionCookie = await getSessionCookie(); @@ -116,22 +137,3 @@ Future uploadConversation(Conversation conversation) async { print(x.statusCode); } - -Future updateConversation(Conversation conversation, { includeUsers = true } ) async { - String sessionCookie = await getSessionCookie(); - - Map conversationJson = await conversation.payloadJson(includeUsers: includeUsers); - - var x = await http.put( - Uri.parse('${dotenv.env["SERVER_URL"]}api/v1/auth/conversations'), - headers: { - 'Content-Type': 'application/json; charset=UTF-8', - 'cookie': sessionCookie, - }, - body: jsonEncode(conversationJson), - ); - - // TODO: Handle errors here - print(x.statusCode); -} - diff --git a/mobile/lib/utils/storage/friends.dart b/mobile/lib/utils/storage/friends.dart index 4da930c..9ed41eb 100644 --- a/mobile/lib/utils/storage/friends.dart +++ b/mobile/lib/utils/storage/friends.dart @@ -13,7 +13,7 @@ import '/utils/storage/session_cookie.dart'; Future 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 updateFriends() async { ); } - - } catch (SocketException) { - return; - } + // } catch (SocketException) { + // return; + // } } diff --git a/mobile/lib/views/authentication/signup.dart b/mobile/lib/views/authentication/signup.dart index 50ef4f0..c9d3447 100644 --- a/mobile/lib/views/authentication/signup.dart +++ b/mobile/lib/views/authentication/signup.dart @@ -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 json) { - return SignupResponse( - status: json['status'], - message: json['message'], - ); - } -} - Future signUp(context, String username, String password, String confirmPassword) async { var keyPair = CryptoUtils.generateRSAKeyPair(); @@ -32,7 +18,7 @@ Future 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: { '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 json) { + return SignupResponse( + status: json['status'], + message: json['message'], + ); + } +} + class SignupWidget extends StatefulWidget { const SignupWidget({Key? key}) : super(key: key); diff --git a/mobile/lib/views/main/conversation/detail.dart b/mobile/lib/views/main/conversation/detail.dart index 922bb66..667077f 100644 --- a/mobile/lib/views/main/conversation/detail.dart +++ b/mobile/lib/views/main/conversation/detail.dart @@ -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 { @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: 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: [ - 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 { color: Theme.of(context).appBarTheme.iconTheme?.color, ), ), - ], - ), - ), - ), ), body: Stack( children: [ diff --git a/mobile/lib/views/main/conversation/list.dart b/mobile/lib/views/main/conversation/list.dart index a41e6cf..155ae2a 100644 --- a/mobile/lib/views/main/conversation/list.dart +++ b/mobile/lib/views/main/conversation/list.dart @@ -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 { @override Widget build(BuildContext context) { return Scaffold( - body: SingleChildScrollView( - physics: const BouncingScrollPhysics(), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SafeArea( - child: Padding( - padding: const EdgeInsets.only(left: 16,right: 16,top: 10), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: const [ - 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 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: [ + 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 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), ), + ), ); } diff --git a/mobile/lib/views/main/conversation/settings.dart b/mobile/lib/views/main/conversation/settings.dart index cbc06bf..bc291f0 100644 --- a/mobile/lib/views/main/conversation/settings.dart +++ b/mobile/lib/views/main/conversation/settings.dart @@ -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 { @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( - 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), diff --git a/mobile/lib/views/main/friend/add_search.dart b/mobile/lib/views/main/friend/add_search.dart new file mode 100644 index 0000000..08b09d3 --- /dev/null +++ b/mobile/lib/views/main/friend/add_search.dart @@ -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 createState() => _FriendAddSearchState(); +} + +class _FriendAddSearchState extends State { + 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: [ + 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: [ + Text( + 'Add Friends', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Theme.of(context).appBarTheme.toolbarTextStyle?.color + ) + ), + ], + ) + ) + ] + ), + ), + ), + ), + body: Stack( + children: [ + 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 searchUsername() async { + if (searchController.text.isEmpty) { + return; + } + + Map 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(); + } +} diff --git a/mobile/lib/views/main/friend/list.dart b/mobile/lib/views/main/friend/list.dart index 4e79fb1..82aa8be 100644 --- a/mobile/lib/views/main/friend/list.dart +++ b/mobile/lib/views/main/friend/list.dart @@ -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 friends; const FriendList({ Key? key, - required this.friends, }) : super(key: key); @override @@ -16,86 +18,83 @@ class FriendList extends StatefulWidget { class _FriendListState extends State { List friends = []; + List friendRequests = []; + List friendsDuplicate = []; + List friendRequestsDuplicate = []; @override Widget build(BuildContext context) { return Scaffold( - body: SingleChildScrollView( - physics: const BouncingScrollPhysics(), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SafeArea( - child: Padding( - padding: const EdgeInsets.only(left: 16,right: 16,top: 10), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - 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: [ - 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: [ + 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 dummySearchList = []; - dummySearchList.addAll(widget.friends); + dummySearchList.addAll(friends); if(query.isNotEmpty) { List 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 { setState(() { friends.clear(); - friends.addAll(widget.friends); + friends.addAll(friends); }); } @override void initState() { super.initState(); - friends.addAll(widget.friends); + initFriends(); + } + + Future 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'), diff --git a/mobile/lib/views/main/friend/list_item.dart b/mobile/lib/views/main/friend/list_item.dart index fd13204..3610ff1 100644 --- a/mobile/lib/views/main/friend/list_item.dart +++ b/mobile/lib/views/main/friend/list_item.dart @@ -18,40 +18,40 @@ class _FriendListItemState extends State { @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: [ - 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)), - ], - ), - ), - ), - ), - ], + behavior: HitTestBehavior.opaque, + onTap: () async { + }, + child: Container( + padding: const EdgeInsets.only(left: 16,right: 16,top: 0,bottom: 20), + 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)), + ], + ), ), + ), ), - ], - ), + ], + ), + ), + ], + ), ), ); } diff --git a/mobile/lib/views/main/friend/request_list_item.dart b/mobile/lib/views/main/friend/request_list_item.dart new file mode 100644 index 0000000..07c7e24 --- /dev/null +++ b/mobile/lib/views/main/friend/request_list_item.dart @@ -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 { + + @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: [ + 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)), + ], + ), + ), + ), + ), + 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 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 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(); + } +} diff --git a/mobile/lib/views/main/home.dart b/mobile/lib/views/main/home.dart index 80f2be5..46e5765 100644 --- a/mobile/lib/views/main/home.dart +++ b/mobile/lib/views/main/home.dart @@ -23,6 +23,7 @@ class Home extends StatefulWidget { class _HomeState extends State { List conversations = []; List friends = []; + List friendRequests = []; MyProfile profile = MyProfile( id: '', username: '', @@ -32,7 +33,7 @@ class _HomeState extends State { int _selectedIndex = 0; List _widgetOptions = [ const ConversationList(conversations: [], friends: []), - const FriendList(friends: []), + const FriendList(), Profile( profile: MyProfile( id: '', @@ -134,7 +135,7 @@ class _HomeState extends State { children: const [ CircularProgressIndicator(), SizedBox(height: 25), - Text("Loading..."), + Text('Loading...'), ], ) ), @@ -152,7 +153,7 @@ class _HomeState extends State { 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 { conversations: conversations, friends: friends, ), - FriendList(friends: friends), + const FriendList(), Profile(profile: profile), ]; isLoading = false; diff --git a/mobile/lib/views/main/profile/profile.dart b/mobile/lib/views/main/profile/profile.dart index 4009961..5127da0 100644 --- a/mobile/lib/views/main/profile/profile.dart +++ b/mobile/lib/views/main/profile/profile.dart @@ -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 { @override Widget build(BuildContext context) { return Scaffold( - body: SingleChildScrollView( - physics: const BouncingScrollPhysics(), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SafeArea( - child: Padding( - padding: const EdgeInsets.only(left: 16,right: 16,top: 10), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: const [ - Text( - 'Profile', - style: TextStyle( - fontSize: 32, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - ), - ), - Padding( - padding: const EdgeInsets.only(top: 16,left: 16,right: 16), - child: Column( - children: [ - 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: [ + usernameHeading(), + const SizedBox(height: 30), + _profileQrCode(), + const SizedBox(height: 30), + settings(), + const SizedBox(height: 30), + logout(), + ], + ) + ), + ); + } }