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.

374 lines
11 KiB

  1. import 'dart:convert';
  2. import 'dart:io';
  3. import 'dart:typed_data';
  4. import 'package:flutter/material.dart';
  5. import 'package:flutter_dotenv/flutter_dotenv.dart';
  6. import 'package:image_picker/image_picker.dart';
  7. import 'package:mime/mime.dart';
  8. import 'package:qr_flutter/qr_flutter.dart';
  9. import 'package:shared_preferences/shared_preferences.dart';
  10. import 'package:sliding_up_panel/sliding_up_panel.dart';
  11. import 'package:http/http.dart' as http;
  12. import '/components/file_picker.dart';
  13. import '/components/flash_message.dart';
  14. import '/components/select_message_ttl.dart';
  15. import '/components/custom_circle_avatar.dart';
  16. import '/components/custom_title_bar.dart';
  17. import '/database/models/my_profile.dart';
  18. import '/utils/encryption/crypto_utils.dart';
  19. import '/utils/storage/database.dart';
  20. import '/utils/encryption/aes_helper.dart';
  21. import '/utils/storage/session_cookie.dart';
  22. import '/utils/storage/write_file.dart';
  23. import '/views/main/profile/change_password.dart';
  24. import '/views/main/profile/change_server_url.dart';
  25. class Profile extends StatefulWidget {
  26. final MyProfile profile;
  27. const Profile({
  28. Key? key,
  29. required this.profile,
  30. }) : super(key: key);
  31. @override
  32. State<Profile> createState() => _ProfileState();
  33. }
  34. class _ProfileState extends State<Profile> {
  35. final PanelController _panelController = PanelController();
  36. bool showFileSelector = false;
  37. @override
  38. Widget build(BuildContext context) {
  39. return Scaffold(
  40. appBar: const CustomTitleBar(
  41. title: Text(
  42. 'Profile',
  43. style: TextStyle(
  44. fontSize: 32,
  45. fontWeight: FontWeight.bold
  46. )
  47. ),
  48. showBack: false,
  49. backgroundColor: Colors.transparent,
  50. ),
  51. body: SlidingUpPanel(
  52. controller: _panelController,
  53. slideDirection: SlideDirection.DOWN,
  54. defaultPanelState: PanelState.CLOSED,
  55. color: Theme.of(context).scaffoldBackgroundColor,
  56. backdropTapClosesPanel: true,
  57. backdropEnabled: true,
  58. backdropOpacity: 0.2,
  59. minHeight: 0,
  60. panel: Center(
  61. child: _profileQrCode(),
  62. ),
  63. body: Padding(
  64. padding: const EdgeInsets.only(top: 16,left: 16,right: 16),
  65. child: Column(
  66. children: <Widget>[
  67. usernameHeading(),
  68. AnimatedSwitcher(
  69. duration: const Duration(milliseconds: 250),
  70. transitionBuilder: (Widget child, Animation<double> animation) {
  71. return SizeTransition(sizeFactor: animation, child: child);
  72. },
  73. child: fileSelector(),
  74. ),
  75. SizedBox(height: showFileSelector ? 10 : 30),
  76. settings(),
  77. const SizedBox(height: 30),
  78. logout(),
  79. ],
  80. )
  81. ),
  82. ),
  83. );
  84. }
  85. Widget usernameHeading() {
  86. return Row(
  87. children: <Widget> [
  88. CustomCircleAvatar(
  89. image: widget.profile.image,
  90. icon: const Icon(Icons.person, size: 40),
  91. radius: 30,
  92. editImageCallback: () {
  93. setState(() {
  94. showFileSelector = true;
  95. });
  96. },
  97. ),
  98. const SizedBox(width: 20),
  99. Expanded(
  100. flex: 1,
  101. child: Text(
  102. widget.profile.username,
  103. style: const TextStyle(
  104. fontSize: 25,
  105. fontWeight: FontWeight.w500,
  106. ),
  107. ),
  108. ),
  109. IconButton(
  110. onPressed: () => _panelController.open(),
  111. icon: const Icon(Icons.qr_code_2),
  112. ),
  113. ],
  114. );
  115. }
  116. Widget fileSelector() {
  117. if (!showFileSelector) {
  118. return const SizedBox.shrink();
  119. }
  120. return Padding(
  121. key: const Key('fileSelector'),
  122. padding: const EdgeInsets.only(top: 10),
  123. child: FilePicker(
  124. cameraHandle: _setProfileImage,
  125. galleryHandleSingle: _setProfileImage,
  126. )
  127. );
  128. }
  129. Future<void> _setProfileImage(XFile image) async {
  130. widget.profile.image = await writeImage(
  131. widget.profile.id,
  132. File(image.path).readAsBytesSync(),
  133. );
  134. setState(() {
  135. showFileSelector = false;
  136. });
  137. saveProfile();
  138. Map<String, dynamic> payload = {
  139. 'data': AesHelper.aesEncrypt(
  140. widget.profile.symmetricKey!,
  141. Uint8List.fromList(widget.profile.image!.readAsBytesSync())
  142. ),
  143. 'mimetype': lookupMimeType(widget.profile.image!.path),
  144. 'extension': getExtension(widget.profile.image!.path),
  145. };
  146. http.post(
  147. await MyProfile.getServerUrl('api/v1/auth/image'),
  148. headers: {
  149. 'cookie': await getSessionCookie(),
  150. },
  151. body: jsonEncode(payload),
  152. ).then((http.Response response) {
  153. if (response.statusCode == 204) {
  154. return;
  155. }
  156. showMessage(
  157. 'Could not add profile picture, please try again later.',
  158. context,
  159. );
  160. });
  161. }
  162. Widget logout() {
  163. bool isTesting = dotenv.env['ENVIRONMENT'] == 'development';
  164. return Align(
  165. alignment: Alignment.centerLeft,
  166. child: Column(
  167. crossAxisAlignment: CrossAxisAlignment.stretch,
  168. children: [
  169. TextButton.icon(
  170. label: const Text(
  171. 'Logout',
  172. style: TextStyle(fontSize: 16)
  173. ),
  174. icon: const Icon(Icons.exit_to_app),
  175. style: ButtonStyle(
  176. foregroundColor: MaterialStateProperty.all<Color>(Theme.of(context).colorScheme.error),
  177. alignment: Alignment.centerLeft,
  178. ),
  179. onPressed: () {
  180. deleteDb();
  181. MyProfile.logout();
  182. Navigator.pushNamedAndRemoveUntil(context, '/landing', ModalRoute.withName('/landing'));
  183. }
  184. ),
  185. isTesting ? TextButton.icon(
  186. label: const Text(
  187. 'Delete Database',
  188. style: TextStyle(fontSize: 16)
  189. ),
  190. icon: const Icon(Icons.delete_forever),
  191. style: ButtonStyle(
  192. foregroundColor: MaterialStateProperty.all<Color>(Theme.of(context).colorScheme.error),
  193. alignment: Alignment.centerLeft,
  194. ),
  195. onPressed: () {
  196. deleteDb();
  197. }
  198. ) : const SizedBox.shrink(),
  199. ],
  200. ),
  201. );
  202. }
  203. Widget settings() {
  204. return Align(
  205. alignment: Alignment.centerLeft,
  206. child: Column(
  207. crossAxisAlignment: CrossAxisAlignment.stretch,
  208. children: [
  209. const SizedBox(height: 5),
  210. TextButton.icon(
  211. label: const Text(
  212. 'Disappearing Messages',
  213. style: TextStyle(fontSize: 16)
  214. ),
  215. icon: const Icon(Icons.timer),
  216. style: ButtonStyle(
  217. alignment: Alignment.centerLeft,
  218. foregroundColor: MaterialStateProperty.resolveWith<Color>(
  219. (Set<MaterialState> states) {
  220. return Theme.of(context).colorScheme.onBackground;
  221. },
  222. )
  223. ),
  224. onPressed: () {
  225. Navigator.of(context).push(
  226. MaterialPageRoute(builder: (context) => SelectMessageTTL(
  227. widgetTitle: 'Message Expiry',
  228. currentSelected: widget.profile.messageExpiryDefault,
  229. backCallback: (String messageExpiry) async {
  230. widget.profile.messageExpiryDefault = messageExpiry;
  231. http.post(
  232. await MyProfile.getServerUrl('api/v1/auth/message_expiry'),
  233. headers: {
  234. 'cookie': await getSessionCookie(),
  235. },
  236. body: jsonEncode({
  237. 'message_expiry': messageExpiry,
  238. }),
  239. ).then((http.Response response) {
  240. if (response.statusCode == 204) {
  241. return;
  242. }
  243. showMessage(
  244. 'Could not change your default message expiry, please try again later.',
  245. context,
  246. );
  247. });
  248. saveProfile();
  249. },
  250. ))
  251. );
  252. }
  253. ),
  254. const SizedBox(height: 5),
  255. TextButton.icon(
  256. label: const Text(
  257. 'Server URL',
  258. style: TextStyle(fontSize: 16)
  259. ),
  260. icon: const Icon(Icons.dataset_linked_outlined),
  261. style: ButtonStyle(
  262. alignment: Alignment.centerLeft,
  263. foregroundColor: MaterialStateProperty.resolveWith<Color>(
  264. (Set<MaterialState> states) {
  265. return Theme.of(context).colorScheme.onBackground;
  266. },
  267. )
  268. ),
  269. onPressed: () {
  270. Navigator.of(context).push(
  271. MaterialPageRoute(builder: (context) => const ChangeServerUrl())
  272. );
  273. }
  274. ),
  275. const SizedBox(height: 5),
  276. TextButton.icon(
  277. label: const Text(
  278. 'Change Password',
  279. style: TextStyle(fontSize: 16)
  280. ),
  281. icon: const Icon(Icons.password),
  282. style: ButtonStyle(
  283. alignment: Alignment.centerLeft,
  284. foregroundColor: MaterialStateProperty.resolveWith<Color>(
  285. (Set<MaterialState> states) {
  286. return Theme.of(context).colorScheme.onBackground;
  287. },
  288. )
  289. ),
  290. onPressed: () {
  291. Navigator.of(context).push(
  292. MaterialPageRoute(builder: (context) => ChangePassword(
  293. privateKey: widget.profile.privateKey!,
  294. ))
  295. );
  296. saveProfile();
  297. }
  298. ),
  299. ],
  300. ),
  301. );
  302. }
  303. Widget _profileQrCode() {
  304. String payload = jsonEncode({
  305. 'i': widget.profile.id,
  306. 'u': widget.profile.username,
  307. 'k': base64.encode(
  308. CryptoUtils.encodeRSAPublicKeyToPem(widget.profile.publicKey!).codeUnits
  309. ),
  310. });
  311. return Column(
  312. children: [
  313. Padding(
  314. padding: const EdgeInsets.all(20),
  315. child: QrImage(
  316. backgroundColor: Theme.of(context).colorScheme.tertiary,
  317. data: payload,
  318. version: QrVersions.auto,
  319. gapless: true,
  320. ),
  321. ),
  322. Align(
  323. alignment: Alignment.centerRight,
  324. child: Padding(
  325. padding: const EdgeInsets.only(right: 20),
  326. child: IconButton(
  327. onPressed: () => _panelController.close(),
  328. icon: const Icon(Icons.arrow_upward),
  329. ),
  330. ),
  331. ),
  332. ]
  333. );
  334. }
  335. Future<void> saveProfile() async {
  336. final preferences = await SharedPreferences.getInstance();
  337. preferences.setString('profile', widget.profile.toJson());
  338. }
  339. }