backup_controller_page.dart 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673
  1. import 'dart:io';
  2. import 'package:auto_route/auto_route.dart';
  3. import 'package:easy_localization/easy_localization.dart';
  4. import 'package:flutter/material.dart';
  5. import 'package:flutter_hooks/flutter_hooks.dart';
  6. import 'package:hooks_riverpod/hooks_riverpod.dart';
  7. import 'package:immich_mobile/modules/backup/background_service/background.service.dart';
  8. import 'package:immich_mobile/modules/backup/providers/error_backup_list.provider.dart';
  9. import 'package:immich_mobile/modules/backup/providers/ios_background_settings.provider.dart';
  10. import 'package:immich_mobile/modules/backup/ui/current_backup_asset_info_box.dart';
  11. import 'package:immich_mobile/modules/backup/ui/ios_debug_info_tile.dart';
  12. import 'package:immich_mobile/modules/login/models/authentication_state.model.dart';
  13. import 'package:immich_mobile/modules/backup/models/backup_state.model.dart';
  14. import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
  15. import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
  16. import 'package:immich_mobile/routing/router.dart';
  17. import 'package:immich_mobile/shared/providers/websocket.provider.dart';
  18. import 'package:immich_mobile/modules/backup/ui/backup_info_card.dart';
  19. import 'package:permission_handler/permission_handler.dart';
  20. import 'package:url_launcher/url_launcher.dart';
  21. class BackupControllerPage extends HookConsumerWidget {
  22. const BackupControllerPage({Key? key}) : super(key: key);
  23. @override
  24. Widget build(BuildContext context, WidgetRef ref) {
  25. BackUpState backupState = ref.watch(backupProvider);
  26. AuthenticationState authenticationState = ref.watch(authenticationProvider);
  27. final settings = ref.watch(iOSBackgroundSettingsProvider.notifier).settings;
  28. final appRefreshDisabled =
  29. Platform.isIOS && settings?.appRefreshEnabled != true;
  30. bool hasExclusiveAccess =
  31. backupState.backupProgress != BackUpProgressEnum.inBackground;
  32. bool shouldBackup = backupState.allUniqueAssets.length -
  33. backupState.selectedAlbumsBackupAssetsIds.length ==
  34. 0 ||
  35. !hasExclusiveAccess
  36. ? false
  37. : true;
  38. var isDarkMode = Theme.of(context).brightness == Brightness.dark;
  39. useEffect(
  40. () {
  41. if (backupState.backupProgress != BackUpProgressEnum.inProgress) {
  42. ref.watch(backupProvider.notifier).getBackupInfo();
  43. }
  44. // Update the background settings information just to make sure we
  45. // have the latest, since the platform channel will not update
  46. // automatically
  47. if (Platform.isIOS) {
  48. ref.watch(iOSBackgroundSettingsProvider.notifier).refresh();
  49. }
  50. ref
  51. .watch(websocketProvider.notifier)
  52. .stopListenToEvent('on_upload_success');
  53. return null;
  54. },
  55. [],
  56. );
  57. Widget buildStorageInformation() {
  58. return ListTile(
  59. leading: Icon(
  60. Icons.storage_rounded,
  61. color: Theme.of(context).primaryColor,
  62. ),
  63. title: const Text(
  64. "backup_controller_page_server_storage",
  65. style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
  66. ).tr(),
  67. subtitle: Padding(
  68. padding: const EdgeInsets.only(top: 8.0),
  69. child: Column(
  70. crossAxisAlignment: CrossAxisAlignment.start,
  71. children: [
  72. Padding(
  73. padding: const EdgeInsets.only(top: 8.0),
  74. child: LinearProgressIndicator(
  75. minHeight: 10.0,
  76. value: backupState.serverInfo.diskUsagePercentage / 100.0,
  77. backgroundColor: Colors.grey,
  78. color: Theme.of(context).primaryColor,
  79. ),
  80. ),
  81. Padding(
  82. padding: const EdgeInsets.only(top: 12.0),
  83. child: const Text('backup_controller_page_storage_format').tr(
  84. args: [
  85. backupState.serverInfo.diskUse,
  86. backupState.serverInfo.diskSize
  87. ],
  88. ),
  89. ),
  90. ],
  91. ),
  92. ),
  93. );
  94. }
  95. ListTile buildAutoBackupController() {
  96. var backUpOption = authenticationState.deviceInfo.isAutoBackup
  97. ? "backup_controller_page_status_on".tr()
  98. : "backup_controller_page_status_off".tr();
  99. var isAutoBackup = authenticationState.deviceInfo.isAutoBackup;
  100. var backupBtnText = authenticationState.deviceInfo.isAutoBackup
  101. ? "backup_controller_page_turn_off".tr()
  102. : "backup_controller_page_turn_on".tr();
  103. return ListTile(
  104. isThreeLine: true,
  105. leading: isAutoBackup
  106. ? Icon(
  107. Icons.cloud_done_rounded,
  108. color: Theme.of(context).primaryColor,
  109. )
  110. : const Icon(Icons.cloud_off_rounded),
  111. title: Text(
  112. backUpOption,
  113. style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
  114. ),
  115. subtitle: Padding(
  116. padding: const EdgeInsets.symmetric(vertical: 8.0),
  117. child: Column(
  118. crossAxisAlignment: CrossAxisAlignment.start,
  119. children: [
  120. if (!isAutoBackup)
  121. const Text(
  122. "backup_controller_page_desc_backup",
  123. style: TextStyle(fontSize: 14),
  124. ).tr(),
  125. Padding(
  126. padding: const EdgeInsets.only(top: 8.0),
  127. child: ElevatedButton(
  128. onPressed: () {
  129. if (isAutoBackup) {
  130. ref
  131. .read(authenticationProvider.notifier)
  132. .setAutoBackup(false);
  133. } else {
  134. ref
  135. .read(authenticationProvider.notifier)
  136. .setAutoBackup(true);
  137. }
  138. },
  139. child: Text(
  140. backupBtnText,
  141. style: const TextStyle(
  142. fontWeight: FontWeight.bold,
  143. fontSize: 12,
  144. ),
  145. ),
  146. ),
  147. )
  148. ],
  149. ),
  150. ),
  151. );
  152. }
  153. void showErrorToUser(String msg) {
  154. final snackBar = SnackBar(
  155. content: Text(
  156. msg.tr(),
  157. ),
  158. backgroundColor: Colors.red,
  159. );
  160. ScaffoldMessenger.of(context).showSnackBar(snackBar);
  161. }
  162. void showBatteryOptimizationInfoToUser() {
  163. showDialog<void>(
  164. context: context,
  165. barrierDismissible: false,
  166. builder: (BuildContext context) {
  167. return AlertDialog(
  168. title: const Text(
  169. 'backup_controller_page_background_battery_info_title',
  170. ).tr(),
  171. content: SingleChildScrollView(
  172. child: const Text(
  173. 'backup_controller_page_background_battery_info_message',
  174. ).tr(),
  175. ),
  176. actions: [
  177. ElevatedButton(
  178. onPressed: () => launchUrl(
  179. Uri.parse('https://dontkillmyapp.com'),
  180. mode: LaunchMode.externalApplication,
  181. ),
  182. child: const Text(
  183. "backup_controller_page_background_battery_info_link",
  184. style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
  185. ).tr(),
  186. ),
  187. ElevatedButton(
  188. child: const Text(
  189. 'backup_controller_page_background_battery_info_ok',
  190. style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
  191. ).tr(),
  192. onPressed: () {
  193. Navigator.of(context).pop();
  194. },
  195. ),
  196. ],
  197. );
  198. },
  199. );
  200. }
  201. Widget buildBackgroundBackupController() {
  202. final bool isBackgroundEnabled = backupState.backgroundBackup;
  203. final bool isWifiRequired = backupState.backupRequireWifi;
  204. final bool isChargingRequired = backupState.backupRequireCharging;
  205. final Color activeColor = Theme.of(context).primaryColor;
  206. String formatBackupDelaySliderValue(double v) {
  207. if (v == 0.0) {
  208. return 'setting_notifications_notify_seconds'.tr(args: const ['5']);
  209. } else if (v == 1.0) {
  210. return 'setting_notifications_notify_seconds'.tr(args: const ['30']);
  211. } else if (v == 2.0) {
  212. return 'setting_notifications_notify_minutes'.tr(args: const ['2']);
  213. } else {
  214. return 'setting_notifications_notify_minutes'.tr(args: const ['10']);
  215. }
  216. }
  217. int backupDelayToMilliseconds(double v) {
  218. if (v == 0.0) {
  219. return 5000;
  220. } else if (v == 1.0) {
  221. return 30000;
  222. } else if (v == 2.0) {
  223. return 120000;
  224. } else {
  225. return 600000;
  226. }
  227. }
  228. double backupDelayToSliderValue(int ms) {
  229. if (ms == 5000) {
  230. return 0.0;
  231. } else if (ms == 30000) {
  232. return 1.0;
  233. } else if (ms == 120000) {
  234. return 2.0;
  235. } else {
  236. return 3.0;
  237. }
  238. }
  239. final triggerDelay =
  240. useState(backupDelayToSliderValue(backupState.backupTriggerDelay));
  241. return Column(
  242. children: [
  243. ListTile(
  244. isThreeLine: true,
  245. leading: isBackgroundEnabled
  246. ? Icon(
  247. Icons.cloud_sync_rounded,
  248. color: activeColor,
  249. )
  250. : const Icon(Icons.cloud_sync_rounded),
  251. title: Text(
  252. isBackgroundEnabled
  253. ? "backup_controller_page_background_is_on"
  254. : "backup_controller_page_background_is_off",
  255. style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
  256. ).tr(),
  257. subtitle: Column(
  258. crossAxisAlignment: CrossAxisAlignment.start,
  259. children: [
  260. if (!isBackgroundEnabled)
  261. Padding(
  262. padding: const EdgeInsets.symmetric(vertical: 8.0),
  263. child: const Text(
  264. "backup_controller_page_background_description",
  265. ).tr(),
  266. ),
  267. if (isBackgroundEnabled && Platform.isAndroid)
  268. SwitchListTile.adaptive(
  269. title: const Text("backup_controller_page_background_wifi")
  270. .tr(),
  271. secondary: Icon(
  272. Icons.wifi,
  273. color: isWifiRequired ? activeColor : null,
  274. ),
  275. dense: true,
  276. activeColor: activeColor,
  277. value: isWifiRequired,
  278. onChanged: (isChecked) => ref
  279. .read(backupProvider.notifier)
  280. .configureBackgroundBackup(
  281. requireWifi: isChecked,
  282. onError: showErrorToUser,
  283. onBatteryInfo: showBatteryOptimizationInfoToUser,
  284. ),
  285. ),
  286. if (isBackgroundEnabled)
  287. SwitchListTile.adaptive(
  288. title:
  289. const Text("backup_controller_page_background_charging")
  290. .tr(),
  291. secondary: Icon(
  292. Icons.charging_station,
  293. color: isChargingRequired ? activeColor : null,
  294. ),
  295. dense: true,
  296. activeColor: activeColor,
  297. value: isChargingRequired,
  298. onChanged: (isChecked) => ref
  299. .read(backupProvider.notifier)
  300. .configureBackgroundBackup(
  301. requireCharging: isChecked,
  302. onError: showErrorToUser,
  303. onBatteryInfo: showBatteryOptimizationInfoToUser,
  304. ),
  305. ),
  306. if (isBackgroundEnabled && Platform.isAndroid)
  307. ListTile(
  308. isThreeLine: false,
  309. dense: true,
  310. title: const Text(
  311. 'backup_controller_page_background_delay',
  312. style: TextStyle(
  313. fontWeight: FontWeight.bold,
  314. ),
  315. ).tr(
  316. args: [formatBackupDelaySliderValue(triggerDelay.value)],
  317. ),
  318. subtitle: Slider(
  319. value: triggerDelay.value,
  320. onChanged: (double v) => triggerDelay.value = v,
  321. onChangeEnd: (double v) => ref
  322. .read(backupProvider.notifier)
  323. .configureBackgroundBackup(
  324. triggerDelay: backupDelayToMilliseconds(v),
  325. onError: showErrorToUser,
  326. onBatteryInfo: showBatteryOptimizationInfoToUser,
  327. ),
  328. max: 3.0,
  329. divisions: 3,
  330. label: formatBackupDelaySliderValue(triggerDelay.value),
  331. activeColor: Theme.of(context).primaryColor,
  332. ),
  333. ),
  334. ElevatedButton(
  335. onPressed: () => ref
  336. .read(backupProvider.notifier)
  337. .configureBackgroundBackup(
  338. enabled: !isBackgroundEnabled,
  339. onError: showErrorToUser,
  340. onBatteryInfo: showBatteryOptimizationInfoToUser,
  341. ),
  342. child: Text(
  343. isBackgroundEnabled
  344. ? "backup_controller_page_background_turn_off"
  345. : "backup_controller_page_background_turn_on",
  346. style: const TextStyle(
  347. fontWeight: FontWeight.bold,
  348. fontSize: 12,
  349. ),
  350. ).tr(),
  351. ),
  352. ],
  353. ),
  354. ),
  355. if (isBackgroundEnabled && Platform.isIOS)
  356. FutureBuilder(
  357. future: ref
  358. .read(backgroundServiceProvider)
  359. .getIOSBackgroundAppRefreshEnabled(),
  360. builder: (context, snapshot) {
  361. final enabled = snapshot.data as bool?;
  362. // If it's not enabled, show them some kind of alert that says
  363. // background refresh is not enabled
  364. if (enabled != null && !enabled) {}
  365. // If it's enabled, no need to bother them
  366. return Container();
  367. },
  368. ),
  369. if (Platform.isIOS && isBackgroundEnabled && settings != null)
  370. IosDebugInfoTile(
  371. settings: settings,
  372. ),
  373. ],
  374. );
  375. }
  376. Widget buildBackgroundAppRefreshWarning() {
  377. return ListTile(
  378. isThreeLine: true,
  379. leading: const Icon(
  380. Icons.task_outlined,
  381. ),
  382. title: const Text(
  383. 'backup_controller_page_background_app_refresh_disabled_title',
  384. style: TextStyle(
  385. fontWeight: FontWeight.bold,
  386. fontSize: 14,
  387. ),
  388. ).tr(),
  389. subtitle: Column(
  390. crossAxisAlignment: CrossAxisAlignment.start,
  391. children: [
  392. Padding(
  393. padding: const EdgeInsets.symmetric(vertical: 8.0),
  394. child: const Text(
  395. 'backup_controller_page_background_app_refresh_disabled_content',
  396. ).tr(),
  397. ),
  398. ElevatedButton(
  399. onPressed: () => openAppSettings(),
  400. child: const Text(
  401. 'backup_controller_page_background_app_refresh_enable_button_text',
  402. style: TextStyle(
  403. fontWeight: FontWeight.bold,
  404. fontSize: 12,
  405. ),
  406. ).tr(),
  407. ),
  408. ],
  409. ),
  410. );
  411. }
  412. Widget buildSelectedAlbumName() {
  413. var text = "backup_controller_page_backup_selected".tr();
  414. var albums = ref.watch(backupProvider).selectedBackupAlbums;
  415. if (albums.isNotEmpty) {
  416. for (var album in albums) {
  417. if (album.name == "Recent" || album.name == "Recents") {
  418. text += "${album.name} (${'backup_all'.tr()}), ";
  419. } else {
  420. text += "${album.name}, ";
  421. }
  422. }
  423. return Padding(
  424. padding: const EdgeInsets.only(top: 8.0),
  425. child: Text(
  426. text.trim().substring(0, text.length - 2),
  427. style: TextStyle(
  428. color: Theme.of(context).primaryColor,
  429. fontSize: 12,
  430. fontWeight: FontWeight.bold,
  431. ),
  432. ),
  433. );
  434. } else {
  435. return Padding(
  436. padding: const EdgeInsets.only(top: 8.0),
  437. child: Text(
  438. "backup_controller_page_none_selected".tr(),
  439. style: TextStyle(
  440. color: Theme.of(context).primaryColor,
  441. fontSize: 12,
  442. fontWeight: FontWeight.bold,
  443. ),
  444. ),
  445. );
  446. }
  447. }
  448. Widget buildExcludedAlbumName() {
  449. var text = "backup_controller_page_excluded".tr();
  450. var albums = ref.watch(backupProvider).excludedBackupAlbums;
  451. if (albums.isNotEmpty) {
  452. for (var album in albums) {
  453. text += "${album.name}, ";
  454. }
  455. return Padding(
  456. padding: const EdgeInsets.only(top: 8.0),
  457. child: Text(
  458. text.trim().substring(0, text.length - 2),
  459. style: TextStyle(
  460. color: Colors.red[300],
  461. fontSize: 12,
  462. fontWeight: FontWeight.bold,
  463. ),
  464. ),
  465. );
  466. } else {
  467. return const SizedBox();
  468. }
  469. }
  470. buildFolderSelectionTile() {
  471. return Card(
  472. shape: RoundedRectangleBorder(
  473. borderRadius: BorderRadius.circular(20),
  474. side: BorderSide(
  475. color: isDarkMode
  476. ? const Color.fromARGB(255, 56, 56, 56)
  477. : Colors.black12,
  478. width: 1,
  479. ),
  480. ),
  481. elevation: 0,
  482. borderOnForeground: false,
  483. child: ListTile(
  484. minVerticalPadding: 15,
  485. title: const Text(
  486. "backup_controller_page_albums",
  487. style: TextStyle(fontWeight: FontWeight.bold, fontSize: 20),
  488. ).tr(),
  489. subtitle: Padding(
  490. padding: const EdgeInsets.only(top: 8.0),
  491. child: Column(
  492. crossAxisAlignment: CrossAxisAlignment.start,
  493. children: [
  494. const Text(
  495. "backup_controller_page_to_backup",
  496. style: TextStyle(fontSize: 12),
  497. ).tr(),
  498. buildSelectedAlbumName(),
  499. buildExcludedAlbumName()
  500. ],
  501. ),
  502. ),
  503. trailing: ElevatedButton(
  504. onPressed: () {
  505. AutoRouter.of(context).push(const BackupAlbumSelectionRoute());
  506. },
  507. child: const Text(
  508. "backup_controller_page_select",
  509. style: TextStyle(
  510. fontWeight: FontWeight.bold,
  511. fontSize: 12,
  512. ),
  513. ).tr(),
  514. ),
  515. ),
  516. );
  517. }
  518. void startBackup() {
  519. ref.watch(errorBackupListProvider.notifier).empty();
  520. if (ref.watch(backupProvider).backupProgress !=
  521. BackUpProgressEnum.inBackground) {
  522. ref.watch(backupProvider.notifier).startBackupProcess();
  523. }
  524. }
  525. Widget buildBackupButton() {
  526. return Padding(
  527. padding: const EdgeInsets.only(
  528. top: 24,
  529. ),
  530. child: Container(
  531. child: backupState.backupProgress == BackUpProgressEnum.inProgress
  532. ? ElevatedButton(
  533. style: ElevatedButton.styleFrom(
  534. foregroundColor: Colors.grey[50],
  535. backgroundColor: Colors.red[300],
  536. // padding: const EdgeInsets.all(14),
  537. ),
  538. onPressed: () {
  539. ref.read(backupProvider.notifier).cancelBackup();
  540. },
  541. child: const Text(
  542. "backup_controller_page_cancel",
  543. style: TextStyle(
  544. fontSize: 14,
  545. fontWeight: FontWeight.bold,
  546. ),
  547. ).tr(),
  548. )
  549. : ElevatedButton(
  550. onPressed: shouldBackup ? startBackup : null,
  551. child: const Text(
  552. "backup_controller_page_start_backup",
  553. style: TextStyle(
  554. fontSize: 14,
  555. fontWeight: FontWeight.bold,
  556. ),
  557. ).tr(),
  558. ),
  559. ),
  560. );
  561. }
  562. buildBackgroundBackupInfo() {
  563. return const ListTile(
  564. leading: Icon(Icons.info_outline_rounded),
  565. title: Text(
  566. "Background backup is currently running, cannot start manual backup",
  567. ),
  568. );
  569. }
  570. return Scaffold(
  571. appBar: AppBar(
  572. elevation: 0,
  573. title: const Text(
  574. "backup_controller_page_backup",
  575. style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
  576. ).tr(),
  577. leading: IconButton(
  578. onPressed: () {
  579. ref.watch(websocketProvider.notifier).listenUploadEvent();
  580. AutoRouter.of(context).pop(true);
  581. },
  582. splashRadius: 24,
  583. icon: const Icon(
  584. Icons.arrow_back_ios_rounded,
  585. ),
  586. ),
  587. ),
  588. body: Padding(
  589. padding: const EdgeInsets.only(left: 16.0, right: 16, bottom: 32),
  590. child: ListView(
  591. // crossAxisAlignment: CrossAxisAlignment.start,
  592. children: [
  593. Padding(
  594. padding: const EdgeInsets.all(8.0),
  595. child: const Text(
  596. "backup_controller_page_info",
  597. style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
  598. ).tr(),
  599. ),
  600. buildFolderSelectionTile(),
  601. BackupInfoCard(
  602. title: "backup_controller_page_total".tr(),
  603. subtitle: "backup_controller_page_total_sub".tr(),
  604. info: ref.watch(backupProvider).availableAlbums.isEmpty
  605. ? "..."
  606. : "${backupState.allUniqueAssets.length}",
  607. ),
  608. BackupInfoCard(
  609. title: "backup_controller_page_backup".tr(),
  610. subtitle: "backup_controller_page_backup_sub".tr(),
  611. info: ref.watch(backupProvider).availableAlbums.isEmpty
  612. ? "..."
  613. : "${backupState.selectedAlbumsBackupAssetsIds.length}",
  614. ),
  615. BackupInfoCard(
  616. title: "backup_controller_page_remainder".tr(),
  617. subtitle: "backup_controller_page_remainder_sub".tr(),
  618. info: ref.watch(backupProvider).availableAlbums.isEmpty
  619. ? "..."
  620. : "${backupState.allUniqueAssets.length - backupState.selectedAlbumsBackupAssetsIds.length}",
  621. ),
  622. const Divider(),
  623. buildAutoBackupController(),
  624. const Divider(),
  625. AnimatedSwitcher(
  626. duration: const Duration(milliseconds: 500),
  627. child: Platform.isIOS
  628. ? (appRefreshDisabled
  629. ? buildBackgroundAppRefreshWarning()
  630. : buildBackgroundBackupController())
  631. : buildBackgroundBackupController(),
  632. ),
  633. const Divider(),
  634. buildStorageInformation(),
  635. const Divider(),
  636. const CurrentUploadingAssetInfoBox(),
  637. if (!hasExclusiveAccess) buildBackgroundBackupInfo(),
  638. buildBackupButton()
  639. ],
  640. ),
  641. ),
  642. );
  643. }
  644. }