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.

372 lines
11 KiB

  1. import 'dart:convert';
  2. import 'dart:io';
  3. import 'dart:typed_data';
  4. import 'package:Capsule/components/file_picker.dart';
  5. import 'package:Capsule/components/flash_message.dart';
  6. import 'package:Capsule/utils/encryption/aes_helper.dart';
  7. import 'package:Capsule/utils/storage/session_cookie.dart';
  8. import 'package:Capsule/utils/storage/write_file.dart';
  9. import 'package:flutter/material.dart';
  10. import 'package:flutter_dotenv/flutter_dotenv.dart';
  11. import 'package:image_picker/image_picker.dart';
  12. import 'package:mime/mime.dart';
  13. import 'package:qr_flutter/qr_flutter.dart';
  14. import 'package:shared_preferences/shared_preferences.dart';
  15. import 'package:sliding_up_panel/sliding_up_panel.dart';
  16. import 'package:http/http.dart' as http;
  17. import '/components/select_message_ttl.dart';
  18. import '/components/custom_circle_avatar.dart';
  19. import '/components/custom_title_bar.dart';
  20. import '/models/my_profile.dart';
  21. import '/utils/encryption/crypto_utils.dart';
  22. import '/utils/storage/database.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: const ButtonStyle(
  176. alignment: Alignment.centerLeft,
  177. ),
  178. onPressed: () {
  179. deleteDb();
  180. MyProfile.logout();
  181. Navigator.pushNamedAndRemoveUntil(context, '/landing', ModalRoute.withName('/landing'));
  182. }
  183. ),
  184. isTesting ? TextButton.icon(
  185. label: const Text(
  186. 'Delete Database',
  187. style: TextStyle(fontSize: 16)
  188. ),
  189. icon: const Icon(Icons.delete_forever),
  190. style: const ButtonStyle(
  191. alignment: Alignment.centerLeft,
  192. ),
  193. onPressed: () {
  194. deleteDb();
  195. }
  196. ) : const SizedBox.shrink(),
  197. ],
  198. ),
  199. );
  200. }
  201. Widget settings() {
  202. return Align(
  203. alignment: Alignment.centerLeft,
  204. child: Column(
  205. crossAxisAlignment: CrossAxisAlignment.stretch,
  206. children: [
  207. const SizedBox(height: 5),
  208. TextButton.icon(
  209. label: const Text(
  210. 'Disappearing Messages',
  211. style: TextStyle(fontSize: 16)
  212. ),
  213. icon: const Icon(Icons.timer),
  214. style: ButtonStyle(
  215. alignment: Alignment.centerLeft,
  216. foregroundColor: MaterialStateProperty.resolveWith<Color>(
  217. (Set<MaterialState> states) {
  218. return Theme.of(context).colorScheme.onBackground;
  219. },
  220. )
  221. ),
  222. onPressed: () {
  223. Navigator.of(context).push(
  224. MaterialPageRoute(builder: (context) => SelectMessageTTL(
  225. widgetTitle: 'Message Expiry',
  226. currentSelected: widget.profile.messageExpiryDefault,
  227. backCallback: (String messageExpiry) async {
  228. widget.profile.messageExpiryDefault = messageExpiry;
  229. http.post(
  230. await MyProfile.getServerUrl('api/v1/auth/message_expiry'),
  231. headers: {
  232. 'cookie': await getSessionCookie(),
  233. },
  234. body: jsonEncode({
  235. 'message_expiry': messageExpiry,
  236. }),
  237. ).then((http.Response response) {
  238. if (response.statusCode == 204) {
  239. return;
  240. }
  241. showMessage(
  242. 'Could not change your default message expiry, please try again later.',
  243. context,
  244. );
  245. });
  246. saveProfile();
  247. },
  248. ))
  249. );
  250. }
  251. ),
  252. const SizedBox(height: 5),
  253. TextButton.icon(
  254. label: const Text(
  255. 'Server URL',
  256. style: TextStyle(fontSize: 16)
  257. ),
  258. icon: const Icon(Icons.dataset_linked_outlined),
  259. style: ButtonStyle(
  260. alignment: Alignment.centerLeft,
  261. foregroundColor: MaterialStateProperty.resolveWith<Color>(
  262. (Set<MaterialState> states) {
  263. return Theme.of(context).colorScheme.onBackground;
  264. },
  265. )
  266. ),
  267. onPressed: () {
  268. Navigator.of(context).push(
  269. MaterialPageRoute(builder: (context) => const ChangeServerUrl())
  270. );
  271. }
  272. ),
  273. const SizedBox(height: 5),
  274. TextButton.icon(
  275. label: const Text(
  276. 'Change Password',
  277. style: TextStyle(fontSize: 16)
  278. ),
  279. icon: const Icon(Icons.password),
  280. style: ButtonStyle(
  281. alignment: Alignment.centerLeft,
  282. foregroundColor: MaterialStateProperty.resolveWith<Color>(
  283. (Set<MaterialState> states) {
  284. return Theme.of(context).colorScheme.onBackground;
  285. },
  286. )
  287. ),
  288. onPressed: () {
  289. Navigator.of(context).push(
  290. MaterialPageRoute(builder: (context) => ChangePassword(
  291. privateKey: widget.profile.privateKey!,
  292. ))
  293. );
  294. saveProfile();
  295. }
  296. ),
  297. ],
  298. ),
  299. );
  300. }
  301. Widget _profileQrCode() {
  302. String payload = jsonEncode({
  303. 'i': widget.profile.id,
  304. 'u': widget.profile.username,
  305. 'k': base64.encode(
  306. CryptoUtils.encodeRSAPublicKeyToPem(widget.profile.publicKey!).codeUnits
  307. ),
  308. });
  309. return Column(
  310. children: [
  311. Padding(
  312. padding: const EdgeInsets.all(20),
  313. child: QrImage(
  314. backgroundColor: Theme.of(context).colorScheme.primary,
  315. data: payload,
  316. version: QrVersions.auto,
  317. gapless: true,
  318. ),
  319. ),
  320. Align(
  321. alignment: Alignment.centerRight,
  322. child: Padding(
  323. padding: const EdgeInsets.only(right: 20),
  324. child: IconButton(
  325. onPressed: () => _panelController.close(),
  326. icon: const Icon(Icons.arrow_upward),
  327. ),
  328. ),
  329. ),
  330. ]
  331. );
  332. }
  333. Future<void> saveProfile() async {
  334. final preferences = await SharedPreferences.getInstance();
  335. preferences.setString('profile', widget.profile.toJson());
  336. }
  337. }