Added custom error message for when interactions failpull/1/head
| @ -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) | |||||
| } | |||||
| @ -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) | |||||
| } | |||||
| @ -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) | |||||
| } | |||||
| @ -1,20 +1,19 @@ | |||||
| package Models | package Models | ||||
| import ( | import ( | ||||
| "time" | |||||
| "database/sql" | |||||
| "github.com/gofrs/uuid" | "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 { | type FriendRequest struct { | ||||
| Base | 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"` | |||||
| } | } | ||||
| @ -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, | |||||
| ), | |||||
| ); | |||||
| } | |||||
| } | |||||
| @ -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, | |||||
| ), | |||||
| ); | |||||
| } | |||||
| } | |||||
| @ -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, | |||||
| ), | |||||
| ); | |||||
| } | |||||
| @ -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); | |||||
| } | |||||
| } | |||||
| @ -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'], | |||||
| ); | |||||
| } | |||||
| } | |||||
| @ -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(); | |||||
| } | |||||
| } | |||||
| @ -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(); | |||||
| } | |||||
| } | |||||