Encrypted messaging app
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

366 lines
10 KiB

import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:Capsule/components/file_picker.dart';
import 'package:Capsule/components/flash_message.dart';
import 'package:Capsule/utils/encryption/aes_helper.dart';
import 'package:Capsule/utils/storage/session_cookie.dart';
import 'package:Capsule/utils/storage/write_file.dart';
import 'package:flutter/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:image_picker/image_picker.dart';
import 'package:mime/mime.dart';
import 'package:qr_flutter/qr_flutter.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:sliding_up_panel/sliding_up_panel.dart';
import 'package:http/http.dart' as http;
import '/components/select_message_ttl.dart';
import '/components/custom_circle_avatar.dart';
import '/components/custom_title_bar.dart';
import '/models/my_profile.dart';
import '/utils/encryption/crypto_utils.dart';
import '/utils/storage/database.dart';
import '/views/main/profile/change_password.dart';
import '/views/main/profile/change_server_url.dart';
class Profile extends StatefulWidget {
final MyProfile profile;
const Profile({
Key? key,
required this.profile,
}) : super(key: key);
@override
State<Profile> createState() => _ProfileState();
}
class _ProfileState extends State<Profile> {
final PanelController _panelController = PanelController();
bool showFileSelector = false;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: const CustomTitleBar(
title: Text(
'Profile',
style: TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold
)
),
showBack: false,
backgroundColor: Colors.transparent,
),
body: SlidingUpPanel(
controller: _panelController,
slideDirection: SlideDirection.DOWN,
defaultPanelState: PanelState.CLOSED,
color: Theme.of(context).scaffoldBackgroundColor,
backdropTapClosesPanel: true,
backdropEnabled: true,
backdropOpacity: 0.2,
minHeight: 0,
maxHeight: 450,
panel: Center(
child: _profileQrCode(),
),
body: Padding(
padding: const EdgeInsets.only(top: 16,left: 16,right: 16),
child: Column(
children: <Widget>[
usernameHeading(),
fileSelector(),
SizedBox(height: showFileSelector ? 10 : 30),
settings(),
const SizedBox(height: 30),
logout(),
],
)
),
),
);
}
Widget usernameHeading() {
return Row(
children: <Widget> [
CustomCircleAvatar(
image: widget.profile.image,
icon: const Icon(Icons.person, size: 40),
radius: 30,
editImageCallback: () {
setState(() {
showFileSelector = true;
});
},
),
const SizedBox(width: 20),
Expanded(
flex: 1,
child: Text(
widget.profile.username,
style: const TextStyle(
fontSize: 25,
fontWeight: FontWeight.w500,
),
),
),
IconButton(
onPressed: () => _panelController.open(),
icon: const Icon(Icons.qr_code_2),
),
],
);
}
Widget fileSelector() {
if (!showFileSelector) {
return const SizedBox.shrink();
}
return Padding(
padding: const EdgeInsets.only(top: 10),
child: FilePicker(
cameraHandle: _setProfileImage,
galleryHandleSingle: _setProfileImage,
)
);
}
Future<void> _setProfileImage(XFile image) async {
widget.profile.image = await writeImage(
widget.profile.id,
File(image.path).readAsBytesSync(),
);
setState(() {
showFileSelector = false;
});
saveProfile();
Map<String, dynamic> payload = {
'data': AesHelper.aesEncrypt(
widget.profile.symmetricKey!,
Uint8List.fromList(widget.profile.image!.readAsBytesSync())
),
'mimetype': lookupMimeType(widget.profile.image!.path),
'extension': getExtension(widget.profile.image!.path),
};
http.post(
await MyProfile.getServerUrl('api/v1/auth/image'),
headers: {
'cookie': await getSessionCookie(),
},
body: jsonEncode(payload),
).then((http.Response response) {
if (response.statusCode == 204) {
return;
}
showMessage(
'Could not change your default message expiry, please try again later.',
context,
);
});
}
Widget logout() {
bool isTesting = dotenv.env['ENVIRONMENT'] == 'development';
return Align(
alignment: Alignment.centerLeft,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextButton.icon(
label: const Text(
'Logout',
style: TextStyle(fontSize: 16)
),
icon: const Icon(Icons.exit_to_app),
style: const ButtonStyle(
alignment: Alignment.centerLeft,
),
onPressed: () {
deleteDb();
MyProfile.logout();
Navigator.pushNamedAndRemoveUntil(context, '/landing', ModalRoute.withName('/landing'));
}
),
isTesting ? TextButton.icon(
label: const Text(
'Delete Database',
style: TextStyle(fontSize: 16)
),
icon: const Icon(Icons.delete_forever),
style: const ButtonStyle(
alignment: Alignment.centerLeft,
),
onPressed: () {
deleteDb();
}
) : const SizedBox.shrink(),
],
),
);
}
Widget settings() {
return Align(
alignment: Alignment.centerLeft,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 5),
TextButton.icon(
label: const Text(
'Disappearing Messages',
style: TextStyle(fontSize: 16)
),
icon: const Icon(Icons.timer),
style: ButtonStyle(
alignment: Alignment.centerLeft,
foregroundColor: MaterialStateProperty.resolveWith<Color>(
(Set<MaterialState> states) {
return Theme.of(context).colorScheme.onBackground;
},
)
),
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (context) => SelectMessageTTL(
widgetTitle: 'Message Expiry',
currentSelected: widget.profile.messageExpiryDefault,
backCallback: (String messageExpiry) async {
widget.profile.messageExpiryDefault = messageExpiry;
http.post(
await MyProfile.getServerUrl('api/v1/auth/message_expiry'),
headers: {
'cookie': await getSessionCookie(),
},
body: jsonEncode({
'message_expiry': messageExpiry,
}),
).then((http.Response response) {
if (response.statusCode == 200) {
return;
}
showMessage(
'Could not change your default message expiry, please try again later.',
context,
);
});
saveProfile();
},
))
);
}
),
const SizedBox(height: 5),
TextButton.icon(
label: const Text(
'Server URL',
style: TextStyle(fontSize: 16)
),
icon: const Icon(Icons.dataset_linked_outlined),
style: ButtonStyle(
alignment: Alignment.centerLeft,
foregroundColor: MaterialStateProperty.resolveWith<Color>(
(Set<MaterialState> states) {
return Theme.of(context).colorScheme.onBackground;
},
)
),
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (context) => const ChangeServerUrl())
);
}
),
const SizedBox(height: 5),
TextButton.icon(
label: const Text(
'Change Password',
style: TextStyle(fontSize: 16)
),
icon: const Icon(Icons.password),
style: ButtonStyle(
alignment: Alignment.centerLeft,
foregroundColor: MaterialStateProperty.resolveWith<Color>(
(Set<MaterialState> states) {
return Theme.of(context).colorScheme.onBackground;
},
)
),
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (context) => ChangePassword(
privateKey: widget.profile.privateKey!,
))
);
saveProfile();
}
),
],
),
);
}
Widget _profileQrCode() {
String payload = jsonEncode({
'i': widget.profile.id,
'u': widget.profile.username,
'k': base64.encode(
CryptoUtils.encodeRSAPublicKeyToPem(widget.profile.publicKey!).codeUnits
),
});
return Column(
children: [
Padding(
padding: const EdgeInsets.all(20),
child: QrImage(
backgroundColor: Theme.of(context).colorScheme.primary,
data: payload,
version: QrVersions.auto,
gapless: true,
),
),
Align(
alignment: Alignment.centerRight,
child: Padding(
padding: const EdgeInsets.only(right: 20),
child: IconButton(
onPressed: () => _panelController.close(),
icon: const Icon(Icons.arrow_upward),
),
),
),
]
);
}
Future<void> saveProfile() async {
final preferences = await SharedPreferences.getInstance();
preferences.setString('profile', widget.profile.toJson());
}
}