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 | |||
| 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"` | |||
| } | |||
| @ -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(); | |||
| } | |||
| } | |||