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