Compare commits

..

401 Commits

Author SHA1 Message Date
Neeraj Gupta
38503e8673 Merge remote-tracking branch 'origin/main' into remote_db 2025-09-02 10:34:47 +05:30
Neeraj Gupta
c5319f2ba8 Merge remote-tracking branch 'origin/main' into remote_db 2025-08-21 17:19:42 +05:30
Neeraj Gupta
5d851d8f90 Add model for localAsset 2025-08-08 16:11:52 +05:30
Neeraj Gupta
dd76317bb0 Clean up EnteFile 2025-08-07 15:45:55 +05:30
Neeraj Gupta
5519981f9c Stop reading redundant data 2025-08-07 15:43:55 +05:30
Neeraj Gupta
1c249560c0 Clean up EnteFile 2025-08-07 15:10:56 +05:30
Neeraj Gupta
130cc9137c File:Duration Switch to get only prop 2025-08-07 13:53:29 +05:30
Neeraj Gupta
95f2d282b5 Clean up EnteFile 2025-08-07 13:51:04 +05:30
Neeraj Gupta
e6668606db Minimize data read 2025-08-07 13:37:43 +05:30
Neeraj Gupta
316c767ffa Merge branch 'reduceLog' into remote_db 2025-08-07 11:32:25 +05:30
Neeraj Gupta
c08b0f7aa4 Build changes 2025-08-07 11:08:15 +05:30
Neeraj Gupta
b5a4bcf98f Clean up EnteFile 2025-08-07 11:08:03 +05:30
Neeraj Gupta
dc06a2e193 Use getter for title 2025-08-07 10:41:20 +05:30
Neeraj Gupta
c29446e00a Add index 2025-08-06 14:51:41 +05:30
Neeraj Gupta
5aa36921d8 Use remoteDB 2025-08-06 12:38:06 +05:30
Neeraj Gupta
3b3af65d74 Rewrite reload and cache clear check 2025-08-06 11:35:12 +05:30
Neeraj Gupta
9ad88d3908 Filter out files for which we don't need to create streams 2025-08-06 10:50:34 +05:30
Neeraj Gupta
012e9091f0 Use remote DB 2025-08-06 10:34:43 +05:30
Neeraj Gupta
8ff0f237e7 Use remote DB 2025-08-06 10:17:27 +05:30
Neeraj Gupta
c80e4a65b8 Set state context early 2025-08-06 09:14:08 +05:30
Neeraj Gupta
c317f2494f Fix parsing 2025-08-06 09:13:42 +05:30
Neeraj Gupta
264b0b151a Merge remote-tracking branch 'origin/main' into remote_db 2025-08-05 14:41:02 +05:30
Neeraj Gupta
e5cb3e7005 Hide uploaded local files from homescreen 2025-08-05 14:31:38 +05:30
Neeraj Gupta
adc1939638 Use existing method 2025-08-05 13:15:12 +05:30
Neeraj Gupta
2cc1c36b7b Flatten all remote asset fields 2025-08-05 13:12:34 +05:30
Neeraj Gupta
b99450615e Remove unused field 2025-08-05 09:35:02 +05:30
Neeraj Gupta
5de1f0c93b Switch to remoteDB 2025-07-31 16:24:23 +05:30
Neeraj Gupta
ee902a5ccb ML typecast fixes 2025-07-31 15:39:53 +05:30
Neeraj Gupta
9e56afaa73 Fixed query 2025-07-31 15:35:48 +05:30
Neeraj Gupta
77f8c3f712 Fix: Specify type 2025-07-31 15:04:43 +05:30
Neeraj Gupta
f7a0a414db Fix: Use new db to get fileKey 2025-07-31 15:02:25 +05:30
Neeraj Gupta
bd49b8464b Minor db query fix 2025-07-31 14:20:55 +05:30
Neeraj Gupta
0cbfa319ba Use remote db for dedupe 2025-07-31 14:15:25 +05:30
Neeraj Gupta
95e05f167c Merge remote-tracking branch 'origin/main' into remote_db 2025-07-31 12:07:04 +05:30
Neeraj Gupta
84ef4c2d0b Clean up 2025-07-31 11:22:24 +05:30
Neeraj Gupta
53c14dff01 Refactor 2025-07-31 10:34:42 +05:30
Neeraj Gupta
02ef29fd8f Consolidate shared assets deletion logic 2025-07-31 10:29:00 +05:30
Neeraj Gupta
1848f1a94b Use remoteDB 2025-07-29 15:14:36 +05:30
Neeraj Gupta
276c38236f Use new db to identify backed up files 2025-07-29 14:53:26 +05:30
Neeraj Gupta
5e3e3b4427 Clean up 2025-07-28 16:53:57 +05:30
Neeraj Gupta
b8dab3ea1c Use new db for archive file count 2025-07-28 14:57:36 +05:30
Neeraj Gupta
2dc32f5339 Strip out exif from metadata 2025-07-28 14:54:40 +05:30
Neeraj Gupta
9fd6bc4974 Fix query 2025-07-28 14:54:24 +05:30
Neeraj Gupta
3740e9e29d Gracefully handle partial cleanup 2025-07-28 14:33:34 +05:30
Neeraj Gupta
118dde38a2 Refactor 2025-07-28 11:48:01 +05:30
Neeraj Gupta
cd15fe86c6 Fix delete 2025-07-28 11:47:55 +05:30
Neeraj Gupta
693a40cc24 Refactor save asset for edits 2025-07-26 12:17:53 +05:30
Neeraj Gupta
2cfa8497da Merge remote-tracking branch 'origin/main' into remote_db 2025-07-26 11:53:48 +05:30
Neeraj Gupta
3f6b9d7ae6 Refactor 2025-07-26 11:13:54 +05:30
Neeraj Gupta
e71b6dbb63 Remove unused method 2025-07-26 11:04:49 +05:30
Neeraj Gupta
44722c40c2 Merge branch 'main' into remote_db 2025-07-24 16:22:57 +05:30
Neeraj Gupta
bfd13b99d2 Fix compile error 2025-07-21 11:36:59 +05:30
Neeraj Gupta
e82d878fa8 Merge branch 'main' into remote_db 2025-07-21 10:46:57 +05:30
Neeraj Gupta
0587813c70 Rename 2025-07-14 14:43:08 +05:30
Neeraj Gupta
b7712a7c51 Clean up 2025-07-14 14:42:16 +05:30
Neeraj Gupta
213f3cd122 Clean up 2025-07-14 11:44:13 +05:30
Neeraj Gupta
d103274da5 Add file matching logic before upload 2025-07-14 11:04:44 +05:30
Neeraj Gupta
28b244ebc5 Use fk to keep updated mapping info 2025-07-14 09:53:59 +05:30
Neeraj Gupta
7d24dea7bc Merge branch 'main' into remote_db 2025-07-11 16:56:34 +05:30
Neeraj Gupta
4a358a7793 Refactor 2025-07-11 11:09:00 +05:30
Neeraj Gupta
699249cd26 Upload [1/x] 2025-07-11 10:15:35 +05:30
Neeraj Gupta
7125ac7419 Simplify 2025-07-10 10:34:50 +05:30
Neeraj Gupta
de67b6e9fc Rename column and minor fixes 2025-07-09 15:20:49 +05:30
Neeraj Gupta
39868de5d9 Update schema for shared assets 2025-07-09 14:22:33 +05:30
Neeraj Gupta
751550d469 Refactor 2025-07-09 12:01:46 +05:30
Neeraj Gupta
6f0034dd9d Merge remote-tracking branch 'origin/main' into remote_db 2025-07-09 10:07:55 +05:30
Neeraj Gupta
7bd6180ebf Reflect backup status for device folder 2025-07-09 10:06:25 +05:30
Neeraj Gupta
5a3ae5f97c Log stack trace 2025-07-09 10:05:45 +05:30
Neeraj Gupta
e447058573 Simplify 2025-07-09 09:08:21 +05:30
Neeraj Gupta
8a7e8b8237 Fix queue clean up on backup folder update 2025-07-08 17:57:38 +05:30
Neeraj Gupta
ed7b1be591 Re-organize 2025-07-08 15:08:56 +05:30
Neeraj Gupta
9a5ef8b634 Clean up 2025-07-08 11:29:37 +05:30
Neeraj Gupta
c2668387b5 Refactor to use new upload media and metadata 2025-07-08 11:01:37 +05:30
Neeraj Gupta
d265cf62c7 Separate model to get upload media 2025-07-08 09:18:57 +05:30
Neeraj Gupta
a67c3b0624 Clean up fields 2025-07-07 16:57:06 +05:30
Neeraj Gupta
306c78fbb0 Refactor 2025-07-07 16:09:40 +05:30
Neeraj Gupta
e4f9dd6b33 Simplify new files notification + avoid redundant compute 2025-07-07 15:57:47 +05:30
Neeraj Gupta
5fec59f0fe Clean up 2025-07-07 15:20:32 +05:30
Neeraj Gupta
53050ca25e Map remote files to local during diff sync 2025-07-07 14:31:37 +05:30
Neeraj Gupta
926aa42168 Merge branch 'main' into remote_db 2025-07-07 09:52:59 +05:30
Neeraj Gupta
f4f8141b99 Move mobile -> mobile/apps/photos 2025-07-04 16:52:07 +05:30
Neeraj Gupta
3d044dd3d4 Refactor metadata computation 2025-07-04 14:06:11 +05:30
Neeraj Gupta
8699ad2f01 New upload media data model 2025-07-04 11:52:27 +05:30
Neeraj Gupta
01aad531f1 Generated changes 2025-07-04 10:05:42 +05:30
Neeraj Gupta
6340d9f646 Simplify 2025-07-04 09:37:17 +05:30
Neeraj Gupta
0f944b1796 Simplify 2025-07-04 09:33:02 +05:30
Neeraj Gupta
1d8533168f Mark fields non-nullable 2025-07-03 17:32:58 +05:30
Neeraj Gupta
2fca1ba534 Add location in shared assets 2025-07-03 16:29:38 +05:30
Neeraj Gupta
2d386c769b Simplify 2025-07-03 11:41:14 +05:30
Neeraj Gupta
1b41d81839 Refactor 2025-07-02 20:08:49 +05:30
Neeraj Gupta
487156c7df Use computer for parsing XMP 2025-07-02 19:32:19 +05:30
Neeraj Gupta
35c54111e7 Remove unused import 2025-07-02 17:24:55 +05:30
Neeraj Gupta
d71340fbdd Refactor 2025-07-02 17:19:35 +05:30
Neeraj Gupta
f32ea85ee2 Refactor 2025-07-02 16:37:06 +05:30
Neeraj Gupta
6397ab888a Merge branch 'main' into remote_db 2025-07-02 14:48:53 +05:30
Neeraj Gupta
5a73043b63 Measure time 2025-07-02 14:13:39 +05:30
Neeraj Gupta
f258c40e98 Measure time 2025-07-02 14:13:20 +05:30
Neeraj Gupta
36b6476049 Use remote DB 2025-07-02 11:32:47 +05:30
Neeraj Gupta
c4c5ea150f Merge branch 'main' into remote_db 2025-07-02 09:30:08 +05:30
Neeraj Gupta
1956b3788b Rename 2025-07-01 15:52:11 +05:30
Neeraj Gupta
72cbddff6d Use upload queue for pending uploads 2025-07-01 15:42:48 +05:30
Neeraj Gupta
17670d5538 Use asset queue db for queueing pending upload 2025-07-01 15:25:52 +05:30
Neeraj Gupta
8cfd80663e Merge branch 'main' into remote_db 2025-07-01 14:55:11 +05:30
Neeraj Gupta
f285e2d706 Use lAsset for video type 2025-06-30 17:49:15 +05:30
Neeraj Gupta
f60e074dd5 Use tag instead of generatedID 2025-06-30 17:00:45 +05:30
Neeraj Gupta
140eae6859 Use tag instead of genID 2025-06-30 16:52:23 +05:30
Neeraj Gupta
23ee022472 Refactor 2025-06-30 16:46:08 +05:30
Neeraj Gupta
b1cf3f9fb0 Refactor live photo 2025-06-30 15:50:46 +05:30
Neeraj Gupta
dbbb80a817 Merge remote-tracking branch 'origin/main' into remote_db 2025-06-30 14:35:46 +05:30
Neeraj Gupta
91a10634cc Remove redundant import 2025-06-30 13:01:53 +05:30
Neeraj Gupta
0a2c230254 Merge remote-tracking branch 'origin/main' into remote_db 2025-06-30 12:24:18 +05:30
Neeraj Gupta
6745b110df Refactor shared assets 2025-06-30 12:23:01 +05:30
Neeraj Gupta
7061161181 Rename 2025-06-30 11:54:41 +05:30
Neeraj Gupta
f0026f0a81 Refactor 2025-06-30 11:52:36 +05:30
Neeraj Gupta
acec985bcb Add shared asset service 2025-06-30 11:31:42 +05:30
Neeraj Gupta
c8103a9e06 Refactor 2025-06-28 13:54:16 +05:30
Neeraj Gupta
02f64ad45f Refactor 2025-06-27 15:32:19 +05:30
Neeraj Gupta
d0931d1d0e Refactor 2025-06-27 15:17:08 +05:30
Neeraj Gupta
5c78de5355 Refactor 2025-06-27 14:08:47 +05:30
Neeraj Gupta
1aa9f61419 Rename 2025-06-27 12:49:00 +05:30
Neeraj Gupta
0f2b51d1a5 Remove redundant method 2025-06-27 12:46:29 +05:30
Neeraj Gupta
9fef560d15 Refactor 2025-06-27 12:02:01 +05:30
Neeraj Gupta
09c7bfd717 refactor 2025-06-26 14:44:44 +05:30
Neeraj Gupta
2ff059a701 Remove unused file 2025-06-26 14:25:05 +05:30
Neeraj Gupta
70b043d34a Migrate restore to new store 2025-06-26 14:19:15 +05:30
Neeraj Gupta
15a00379b5 Use remoteDB for linking 2025-06-26 10:01:25 +05:30
Neeraj Gupta
7246ade2ae Rename 2025-06-26 09:50:42 +05:30
Neeraj Gupta
7cad0a83d2 Clean up 2025-06-26 00:04:00 +05:30
Neeraj Gupta
e6703aef65 Simplify 2025-06-25 23:38:03 +05:30
Neeraj Gupta
662dfad7ca Remove unused method 2025-06-25 23:20:18 +05:30
Neeraj Gupta
466ab30f8b Improve handling of empty collection for cover 2025-06-25 22:55:04 +05:30
Neeraj Gupta
12c1845a5f Clean up 2025-06-25 22:43:44 +05:30
Neeraj Gupta
2d0202df36 Refactor magic metadata 2025-06-25 22:24:17 +05:30
Neeraj Gupta
2ec4f5a7e5 Use rAsset for metadata 2025-06-25 21:55:14 +05:30
Neeraj Gupta
6723bed1b0 Refactor filemagic service to use rAsset 2025-06-25 17:22:09 +05:30
Neeraj Gupta
84e8ce519e Fix error parsing for internal dialog 2025-06-25 16:43:05 +05:30
Neeraj Gupta
1b50528181 Remove unused filed 2025-06-25 14:58:23 +05:30
Neeraj Gupta
08dff77ad4 Refactor 2025-06-25 14:58:11 +05:30
Neeraj Gupta
713abce89a Refactor: get size from remoteAsset 2025-06-25 13:34:17 +05:30
Neeraj Gupta
da15593a47 Remove unused method 2025-06-25 12:44:40 +05:30
Neeraj Gupta
fc31cc61d1 Avoid redundant async 2025-06-25 12:44:34 +05:30
Neeraj Gupta
9c6259b713 Use remote db for favroite cache 2025-06-25 12:29:50 +05:30
Neeraj Gupta
ed603232a5 Merge branch 'main' into remote_db 2025-06-25 12:29:27 +05:30
Neeraj Gupta
ccd89d3451 Remove unused method 2025-06-25 11:28:28 +05:30
Neeraj Gupta
647b2ef4a7 Cache remoteAssets entry 2025-06-25 11:07:50 +05:30
Neeraj Gupta
f54c79462e Clear remote db on logout 2025-06-25 11:06:10 +05:30
Neeraj Gupta
eb81d96ddf Fix incorrect request key 2025-06-25 11:05:44 +05:30
Neeraj Gupta
57363a24ef Fix minor bugs 2025-06-25 09:23:34 +05:30
Neeraj Gupta
9431995e8c Clean up 2025-06-24 14:35:06 +05:30
Neeraj Gupta
64b86376f6 Cleanup 2025-06-24 13:31:58 +05:30
Neeraj Gupta
a825367c49 Merge remote-tracking branch 'origin/main' into remote_db 2025-06-24 12:42:22 +05:30
Neeraj Gupta
1ffbb27ac5 Fix schema 2025-06-24 12:42:07 +05:30
Neeraj Gupta
d4add9f7ef Fix 2025-06-24 12:41:54 +05:30
Neeraj Gupta
541494613f Use CF for file key derivation 2025-06-24 12:19:55 +05:30
Neeraj Gupta
2b3427e40b Track shared assets separately 2025-06-24 11:55:28 +05:30
Neeraj Gupta
a57c9e881d Use remote db 2025-06-24 09:49:51 +05:30
Neeraj Gupta
d15f1e15ce Use remote db for file copy 2025-06-23 16:30:26 +05:30
Neeraj Gupta
0411f8ad40 Clean up 2025-06-23 15:04:42 +05:30
Neeraj Gupta
2981816c90 Use remotedb for trash 2025-06-23 14:54:48 +05:30
Neeraj Gupta
a6de98ef68 Add trash time 2025-06-23 14:35:13 +05:30
Neeraj Gupta
18156ce8bc Add trash group type 2025-06-23 14:24:56 +05:30
Neeraj Gupta
458c1cf86d Remove log 2025-06-21 11:28:05 +05:30
Neeraj Gupta
90c0874608 Merge branch 'main' into remote_db 2025-06-21 10:13:22 +05:30
Neeraj Gupta
928ffba4d7 rename 2025-06-21 09:14:01 +05:30
Neeraj Gupta
0701212540 Clean up 2025-06-21 08:46:49 +05:30
Neeraj Gupta
347bf4d2e0 Move 2025-06-21 07:57:41 +05:30
Neeraj Gupta
2729edfded rename 2025-06-21 07:57:26 +05:30
Neeraj Gupta
4e8d2c5cea Use local or remote id for selection 2025-06-21 07:56:55 +05:30
Neeraj Gupta
84e9336672 Use new abstraction to fetch public files 2025-06-20 14:22:02 +05:30
Neeraj Gupta
cecdea3f93 Rename 2025-06-20 13:21:14 +05:30
Neeraj Gupta
37674deba0 Implement move 2025-06-20 13:02:42 +05:30
Neeraj Gupta
733be57df8 Merge remote-tracking branch 'origin/main' into remote_db 2025-06-19 22:49:29 +05:30
Neeraj Gupta
74df52baf1 rename 2025-06-19 16:28:35 +05:30
Neeraj Gupta
b817c4475e Refactor 2025-06-19 16:09:22 +05:30
Neeraj Gupta
f95dac31d2 Merge remote-tracking branch 'origin/main' into remote_db 2025-06-19 11:44:20 +05:30
Neeraj Gupta
151289b24a Use remoteDB during add to collection 2025-06-17 17:24:16 +05:30
Neeraj Gupta
5dac9d4dd6 Rename 2025-06-17 16:44:13 +05:30
Neeraj Gupta
43b9dbdc54 Refactor 2025-06-17 16:14:56 +05:30
Neeraj Gupta
9d3caaa5d5 Use remoteDB to get files within duration 2025-06-17 15:45:13 +05:30
Neeraj Gupta
7eda2ed24e remove unused import 2025-06-17 14:24:23 +05:30
Neeraj Gupta
30df5271b4 Fix query 2025-06-17 14:24:12 +05:30
Neeraj Gupta
4b1f7612a3 Rename 2025-06-17 14:15:40 +05:30
Neeraj Gupta
bf6521e8d5 Use new remoteDB 2025-06-17 14:00:50 +05:30
Neeraj Gupta
4bac1bcb1d Merge remote-tracking branch 'origin/main' into remote_db 2025-06-17 13:34:12 +05:30
Neeraj Gupta
b123635584 Merge remote-tracking branch 'origin/main' into remote_db 2025-06-17 12:33:29 +05:30
Neeraj Gupta
d815143bb4 Rewrite upload queue logic 1/x 2025-06-16 14:02:12 +05:30
Neeraj Gupta
ff6228497f Add tables to support shared media & upload tracking 2025-06-11 17:02:03 +05:30
Neeraj Gupta
7469578e77 Merge branch 'main' into remote_db 2025-06-11 12:38:31 +05:30
Neeraj Gupta
76afef6149 Support for persiting path backup config 2025-06-10 15:58:18 +05:30
Neeraj Gupta
2b3178495a Fix build 2025-06-09 21:54:46 +05:30
Neeraj Gupta
d6f3ff8db3 Merge branch 'main' into remote_db 2025-06-09 13:15:33 +05:30
Neeraj Gupta
7b0ef2b0c0 Add table to store path backup config 2025-06-03 15:52:36 +05:30
Neeraj Gupta
35f95010ea Merge branch 'main' into remote_db 2025-06-03 12:46:53 +05:30
Neeraj Gupta
233f0ec1e1 Fix shared collections decryption 2025-05-28 16:36:46 +05:30
Neeraj Gupta
64820ff5fa Merge branch 'main' into remote_db 2025-05-28 16:36:30 +05:30
Neeraj Gupta
86ffd4e1e6 Merge branch 'main' into remote_db 2025-05-26 16:27:39 +05:30
Neeraj Gupta
436a02d352 Merge branch 'main' into remote_db 2025-05-19 15:34:32 +05:30
Neeraj Gupta
0f3b8bae48 Add models for upload mapping 2025-05-09 15:12:25 +05:30
Neeraj Gupta
0f270a379f Rename 2025-05-08 16:57:10 +05:30
Neeraj Gupta
609f6b8e18 Merge branch 'main' into remote_db 2025-05-08 15:28:06 +05:30
Neeraj Gupta
7896b397c2 Merge branch 'main' into remote_db 2025-05-08 13:08:08 +05:30
Neeraj Gupta
097078bd24 Switch to remoteDB 2025-05-02 16:34:11 +05:30
Neeraj Gupta
e43e3c4230 Switch to remoteDB 2025-05-02 16:26:18 +05:30
Neeraj Gupta
439f1ff0fb keep reference to collectionFileEntry 2025-05-02 16:09:44 +05:30
Neeraj Gupta
e5d78cfd99 Remove unused method 2025-05-02 16:09:26 +05:30
Neeraj Gupta
bd76e66abf perf refactor 2025-05-02 15:41:58 +05:30
Neeraj Gupta
9e6d7908a9 Refactor 2025-05-02 15:14:58 +05:30
Neeraj Gupta
281735e172 Show hidden files from latestDB 2025-05-02 12:28:18 +05:30
Neeraj Gupta
bc93aca110 Merge remote-tracking branch 'origin/main' into remote_db 2025-05-02 11:00:02 +05:30
Neeraj Gupta
df6e409ca1 Remove debugCaption 2025-04-30 15:52:35 +05:30
Neeraj Gupta
7489821434 Refactor 2025-04-30 15:50:31 +05:30
Neeraj Gupta
52e0f04ec2 Merge remote-tracking branch 'origin/main' into remote_db 2025-04-30 15:06:51 +05:30
Neeraj Gupta
ad88ce632c Remove unused method 2025-04-30 14:32:22 +05:30
Neeraj Gupta
26222ec836 Use remoteDB 2025-04-30 14:10:04 +05:30
Neeraj Gupta
beeaee4fd9 Switch to remote db 1/51 2025-04-30 13:18:17 +05:30
Neeraj Gupta
dc98c7bcf5 Remove unused method 2025-04-30 13:11:19 +05:30
Neeraj Gupta
82497563c2 Switch to remoteDB 2025-04-30 13:03:58 +05:30
Neeraj Gupta
4a9b4520d2 Rename 2025-04-30 12:25:52 +05:30
Neeraj Gupta
756c8e5b7d rename 2025-04-30 12:14:53 +05:30
Neeraj Gupta
dffb920cab Switch to remoteDB 2025-04-30 12:12:34 +05:30
Neeraj Gupta
fa7ddbba0c Merge branch 'main' into remote_db 2025-04-30 11:20:44 +05:30
Neeraj Gupta
13a068969c Catch uncaught exception to avoid splash screen issue 2025-04-30 11:10:18 +05:30
Neeraj Gupta
717e8c8b7e Disable iOS battery check in debugmode 2025-04-30 11:09:59 +05:30
Neeraj Gupta
015adb595c Support for DB filters on home screen 2025-04-30 10:37:53 +05:30
Neeraj Gupta
91635d2e7d Schema update 2025-04-30 10:32:10 +05:30
Neeraj Gupta
cd3499a004 Merge branch 'main' into remote_db 2025-04-30 09:27:09 +05:30
Neeraj Gupta
92a964cda6 Remove unused method 2025-04-28 15:47:00 +05:30
Neeraj Gupta
a9ba615962 Switch to new remotedb 2025-04-28 15:36:43 +05:30
Neeraj Gupta
f2b0c11622 Merge remote-tracking branch 'origin/main' into remote_db 2025-04-28 13:55:46 +05:30
Neeraj Gupta
83b89b6bbf Merge branch 'main' into remote_db 2025-04-28 13:55:26 +05:30
Neeraj Gupta
ee0a858302 Merge branch 'main' into remote_db 2025-04-28 09:52:10 +05:30
Neeraj Gupta
b34c923a66 merge files for home screen 2025-04-25 13:04:30 +05:30
Neeraj Gupta
fd927d038b Fix minor bugs 2025-04-25 12:36:57 +05:30
Neeraj Gupta
64e9902f57 Model & schema for local mapping 2025-04-25 12:36:33 +05:30
Neeraj Gupta
c3af79d113 Merge branch 'main' into remote_db 2025-04-24 08:50:49 +05:30
Neeraj Gupta
d87e679650 use remoteDB for collection cover 2025-04-23 12:00:21 +05:30
Neeraj Gupta
7e2242dc69 Show collection files 2025-04-22 16:34:47 +05:30
Neeraj Gupta
9adc207b02 Gradually load on device files 2025-04-22 15:10:09 +05:30
Neeraj Gupta
36049f6633 Treat default 0.0,0.0 as missing location 2025-04-22 14:14:32 +05:30
Neeraj Gupta
9341bc95ee Clean up 2025-04-22 13:57:38 +05:30
Neeraj Gupta
252dca1a01 Clean up 2025-04-22 13:07:53 +05:30
Neeraj Gupta
70501054d2 use remoteDb 2025-04-22 12:50:14 +05:30
Neeraj Gupta
be012e0a28 use remoteDb 2025-04-22 12:18:13 +05:30
Neeraj Gupta
340a0c097f Remove unused method 2025-04-22 12:11:20 +05:30
Neeraj Gupta
7fc8649455 use remoteDb 2025-04-22 11:58:56 +05:30
Neeraj Gupta
c97a313edb use remoteDb 2025-04-22 11:52:34 +05:30
Neeraj Gupta
480fdc84dc Merge remote-tracking branch 'origin/main' into remote_db 2025-04-22 11:52:10 +05:30
Neeraj Gupta
c92ef45c9a Refactor 2025-04-21 16:31:39 +05:30
Neeraj Gupta
ca62012a6f Store local mapping & creation time for remote files 2025-04-21 15:04:42 +05:30
Neeraj Gupta
151a0d13a4 Sync remote assets to local 2025-04-18 14:00:14 +05:30
Neeraj Gupta
747b1b84c6 Upsert instead of replace 2025-04-18 13:59:15 +05:30
Neeraj Gupta
e060fb9823 Disable metadata and ml indexing 2025-04-18 12:14:39 +05:30
Neeraj Gupta
cd377149bc Fix parsing 2025-04-18 11:48:23 +05:30
Neeraj Gupta
d9e22a489b Fix count query 2025-04-18 11:47:34 +05:30
Neeraj Gupta
524db74bf5 Read collection file count 2025-04-17 16:45:36 +05:30
Neeraj Gupta
e1222d51a9 Merge branch 'main' into remote_db 2025-04-17 16:17:36 +05:30
Neeraj Gupta
1c68f0bb60 Refactor 2025-04-17 15:30:40 +05:30
Neeraj Gupta
f6419caf5c Disable metadata scan on ios 2025-04-17 13:50:48 +05:30
Neeraj Gupta
441bcbd187 todo 2025-04-17 13:35:07 +05:30
Neeraj Gupta
4ad3927348 Add trigger to clean up stale collection entries 2025-04-17 13:34:51 +05:30
Neeraj Gupta
4ae15e5966 Remove unused import 2025-04-17 12:17:33 +05:30
Neeraj Gupta
d67d1d3df8 Index local files 2025-04-16 15:57:08 +05:30
Neeraj Gupta
07e1d33ca8 Merge branch 'main' into remote_db 2025-04-16 10:45:51 +05:30
Neeraj Gupta
50e15fa56c offline indexing 1/x 2025-04-15 16:03:39 +05:30
Neeraj Gupta
3f262c5ba2 Add db for offline ml data 2025-04-15 15:40:27 +05:30
Neeraj Gupta
7f34870e3a Remove unused code 2025-04-15 15:39:56 +05:30
Neeraj Gupta
8ba5013926 Merge branch 'main' into remote_db 2025-04-15 11:12:12 +05:30
Neeraj Gupta
e9a24efecb Make ml related classes generic 2025-04-14 16:23:26 +05:30
Neeraj Gupta
eaf74e4059 Merge branch 'main' into remote_db 2025-04-14 10:44:22 +05:30
Neeraj Gupta
e9e1c3ca27 Add wrapper for local metadata 2025-04-12 12:51:45 +05:30
Neeraj Gupta
eb34533aed Add enum for processing state 2025-03-29 13:42:57 +05:30
Neeraj Gupta
cd042e741e Merge branch 'main' into remote_db 2025-03-27 16:43:32 +05:30
Neeraj Gupta
6e944b0b55 Process latest assets first 2025-03-27 13:56:47 +05:30
Neeraj Gupta
18b6b499dd Store time in microseconds 2025-03-27 13:51:34 +05:30
Neeraj Gupta
9205ef8219 Fix: droid metadata parsing 2025-03-27 13:50:59 +05:30
Neeraj Gupta
1ecdbdb88e Fix: get device album assets in desc order 2025-03-27 13:42:18 +05:30
Neeraj Gupta
e2bb4d723e Perform local medatascan for droid 2025-03-27 10:46:33 +05:30
Neeraj Gupta
cfe32c47f0 Merge branch 'main' into remote_db 2025-03-27 06:34:35 +05:30
Neeraj Gupta
a7f6b6589d Service for parsing local metadata 2025-03-26 22:11:59 +05:30
Neeraj Gupta
715e305e09 Add local metadata models 2025-03-26 22:08:43 +05:30
Neeraj Gupta
f7330be52c Reduce queue size 2025-03-26 16:44:08 +05:30
Neeraj Gupta
4cce54a0c6 Use local files for search 2025-03-26 16:42:15 +05:30
Neeraj Gupta
1850e9a2a6 Refactor 2025-03-26 16:24:51 +05:30
Neeraj Gupta
21e2b589cc Minor refactor 2025-03-26 16:23:34 +05:30
Neeraj Gupta
b7f8deb452 Disable smart memories in debug mode 2025-03-26 16:22:59 +05:30
Neeraj Gupta
b6ffb3ca22 Simplify schema 2025-03-26 14:39:29 +05:30
Neeraj Gupta
9351a52800 Clean up imports 2025-03-26 12:46:03 +05:30
Neeraj Gupta
c7510024c0 Use db query to get files on on device albums 2025-03-26 12:43:53 +05:30
Neeraj Gupta
63d3b1c94b Show files within on device albums 2025-03-25 16:10:50 +05:30
Neeraj Gupta
cec27b40a4 Show local device albums 2025-03-25 15:41:16 +05:30
Neeraj Gupta
2f8d0d1957 Delete old local sync 2025-03-25 15:10:49 +05:30
Neeraj Gupta
7f0a36f110 Merge branch 'main' into remote_db 2025-03-25 14:29:12 +05:30
Neeraj Gupta
355367a601 Stop photoManager callback for old local sync 2025-03-25 13:19:09 +05:30
Neeraj Gupta
c66da422cd Update local asset cache 2025-03-25 12:31:15 +05:30
Neeraj Gupta
6f90fad4a2 Fix: Store localSync time in ms 2025-03-25 12:30:52 +05:30
Neeraj Gupta
bcd6f55376 Fix: Use parameterized query params 2025-03-25 12:27:26 +05:30
Neeraj Gupta
b2766a0d4f Use thumbnail widget in the backfolder screen 2025-03-25 11:38:35 +05:30
Neeraj Gupta
ec1b95b0cd Merge branch 'main' into remote_db 2025-03-25 11:35:03 +05:30
Neeraj Gupta
4369317a4d Use lock for full sync 2025-03-25 05:47:47 +05:30
Neeraj Gupta
b18298dc62 Merge remote-tracking branch 'origin/main' into remote_db 2025-03-25 05:43:36 +05:30
Neeraj Gupta
b3e467a1a4 Return early 2025-03-25 05:43:21 +05:30
Neeraj Gupta
f8cd3d9fb4 Add retry on task cancelation 2025-03-24 17:15:18 +05:30
Neeraj Gupta
6d4756ca4b Undo cancellation 2025-03-24 16:16:44 +05:30
Neeraj Gupta
676bbb4d88 Add thumb queue 2025-03-24 16:14:43 +05:30
Neeraj Gupta
da8edfd34e Use new cache for inMem thumbnails 2025-03-24 15:43:42 +05:30
Neeraj Gupta
bf453cfaac Fix selection 2025-03-24 15:10:22 +05:30
Neeraj Gupta
35f41f044e Load thumbnail from cache 2025-03-24 15:09:36 +05:30
Neeraj Gupta
acff269695 Add global cache for image thumbnail 2025-03-24 14:58:24 +05:30
Neeraj Gupta
2b4f96dbb7 Remove task queue from existing thumb fetch 2025-03-24 14:01:50 +05:30
Neeraj Gupta
d5796e2abb Use local thumb provider for thumbnails 2025-03-24 14:00:59 +05:30
Neeraj Gupta
afe9690891 Keep reference for assetEntity 2025-03-24 11:39:20 +05:30
Neeraj Gupta
fc619bbd03 Use task queue to throttle local thumbnail fetch 2025-03-22 21:53:31 +05:30
Neeraj Gupta
a198331ffd Skip reading on disk cache for local thumbnails 2025-03-22 16:26:35 +05:30
Neeraj Gupta
c58fe5358d Add task queue 2025-03-22 16:13:17 +05:30
Neeraj Gupta
defc5164b9 Merge remote-tracking branch 'origin/main' into remote_db 2025-03-22 15:17:42 +05:30
Neeraj Gupta
97d4fb0693 Generated changes 2025-03-22 15:17:30 +05:30
Neeraj Gupta
e708564cb9 Show local assets on home screen 2025-03-22 15:13:19 +05:30
Neeraj Gupta
92068e026b Fix mapping 2025-03-22 14:16:07 +05:30
Neeraj Gupta
8a079ab4f4 Disable existing local & remote sync 2025-03-22 13:27:44 +05:30
Neeraj Gupta
b41c57cb8d Speed up backup folder selection page 2025-03-22 13:26:20 +05:30
Neeraj Gupta
87167e49fc Remove deadlock 2025-03-21 16:59:20 +05:30
Neeraj Gupta
089d1dcd10 Copy ios proj from main 2025-03-21 14:26:03 +05:30
Neeraj Gupta
db02e66124 Merge branch 'main' into remote_db 2025-03-21 14:18:05 +05:30
Neeraj Gupta
964066bf31 Merge remote-tracking branch 'origin/main' into remote_db 2025-03-21 11:09:22 +05:30
Neeraj Gupta
643220e595 Merge branch 'main' into remote_db 2025-03-20 16:47:55 +05:30
Neeraj Gupta
e871498161 Disable remote sync 2025-03-20 15:06:06 +05:30
Neeraj Gupta
31a88b74df Merge branch 'main' into remote_db 2025-03-20 14:05:00 +05:30
Neeraj Gupta
1801258fea Show thumbnail on backup folder 2025-03-19 22:33:22 +05:30
Neeraj Gupta
27acc2125b Fix log 2025-03-19 21:45:48 +05:30
Neeraj Gupta
60b7a91756 Add support for caching local db 2025-03-19 21:45:31 +05:30
Neeraj Gupta
b38a01820d Avoid redundant db txn 2025-03-19 16:25:18 +05:30
Neeraj Gupta
347d5a7a72 Add local image provider for thumbnail 2025-03-19 16:03:47 +05:30
Neeraj Gupta
82979ac729 Fix bug in computing fullDiff 2025-03-19 14:09:22 +05:30
Neeraj Gupta
5768eeb1fe Use trigger instead of fk to avoid redundant deletion 2025-03-19 14:07:58 +05:30
Neeraj Gupta
7a9ff9877a Add new screen for backup 2025-03-19 14:00:51 +05:30
Neeraj Gupta
3565540a61 get assetPath from local 2025-03-19 11:34:41 +05:30
Neeraj Gupta
06d78e5d6a Merge remote-tracking branch 'origin/main' into remote_db 2025-03-19 10:29:54 +05:30
Neeraj Gupta
6c11b76c11 [mob] Refactor 2025-03-17 17:16:43 +05:30
Neeraj Gupta
749cfde7d8 [mob] Support for enabling foreign key 2025-03-17 16:28:49 +05:30
Neeraj Gupta
6bcaa8ae26 [mob] Refactor 2025-03-17 16:27:12 +05:30
Neeraj Gupta
99c6318b0e Merge branch 'db_refactor' into remote_db 2025-03-17 15:35:02 +05:30
Neeraj Gupta
abf789a4aa rename 2025-03-17 14:12:39 +05:30
Neeraj Gupta
f5d3712cbb [mob] Remove unused code + update debouncer 2025-03-17 13:58:01 +05:30
Neeraj Gupta
45dd540abc [mob] move 2025-03-17 13:40:29 +05:30
Neeraj Gupta
865a736bdd [mob] Switch to leading debouncer 2025-03-17 13:29:35 +05:30
Neeraj Gupta
75bd1bfef6 [mob] Avoid running two fullSync 2025-03-17 13:27:57 +05:30
Neeraj Gupta
4cf67fe171 [mob] Fix delete from local db 2025-03-17 12:32:11 +05:30
Neeraj Gupta
65895328dc [mob] Fix full diff computation 2025-03-17 11:46:03 +05:30
Neeraj Gupta
a1c20b9c8a Merge remote-tracking branch 'origin/main' into remote_db 2025-03-17 11:25:45 +05:30
Neeraj Gupta
de9def5370 [mob] Rename 2025-03-17 11:25:24 +05:30
Neeraj Gupta
9cc723a280 [mob] Local local device assets info in db 2025-03-17 11:03:04 +05:30
Neeraj Gupta
16beae2a82 [mob] Rename 2025-03-15 21:56:10 +05:30
Neeraj Gupta
2e25c38324 [mob] Store local assets state in db 2025-03-15 21:53:47 +05:30
Neeraj Gupta
7e691f84e4 Merge remote-tracking branch 'origin/main' into remote_db 2025-03-15 16:24:42 +05:30
Neeraj Gupta
d99593fc85 [mob] Fix impl diff compute & simplify 2025-03-14 18:30:11 +05:30
Neeraj Gupta
d4877ea446 [mob] Write in parallel 2025-03-14 17:05:16 +05:30
Neeraj Gupta
5f4c748886 [mob] Refactor & improve logs 2025-03-14 16:17:59 +05:30
Neeraj Gupta
eaab58c62a [mob] refactor 2025-03-14 13:48:34 +05:30
Neeraj Gupta
c1c020402e [mob] Simplify local import 2025-03-14 13:10:44 +05:30
Neeraj Gupta
028f4e61d2 [mob] Add initial db schema for local files 2025-03-13 17:19:47 +05:30
Neeraj Gupta
abc6f56247 [mob] Fix: Handle null nonce for shared collections 2025-03-13 17:12:26 +05:30
Neeraj Gupta
822eb59761 Merge branch 'main' into remote_db 2025-03-13 15:07:20 +05:30
Neeraj Gupta
fade7859ab Refactor 2025-03-13 11:34:33 +05:30
Neeraj Gupta
f85047fb28 [mob] Refactor 2025-03-13 11:21:50 +05:30
Neeraj Gupta
3f81c9beae [mob] Add helpers for local assets import 2025-03-13 09:55:53 +05:30
Neeraj Gupta
b7fa8d7c89 Merge branch 'main' into remote_db 2025-03-12 13:59:12 +05:30
Neeraj Gupta
5f75c5fc3f [mob] Add sql scripts for local db 2025-03-11 14:06:13 +05:30
Neeraj Gupta
98eaee3b9e Merge branch 'main' into remote_db 2025-03-11 13:35:36 +05:30
Neeraj Gupta
59bd039bed Add db schema for local files 2025-03-10 11:07:17 +05:30
Neeraj Gupta
3e90126a55 Merge branch 'main' into remote_db 2025-03-07 13:47:42 +05:30
Neeraj Gupta
dabcc0aeb5 Merge remote-tracking branch 'origin/main' into remote_db 2025-03-05 13:35:37 +05:30
Neeraj Gupta
c4b99af0e2 Merge branch 'main' into remote_db 2025-03-03 19:07:38 +05:30
Neeraj Gupta
679c12bb90 Remove recursion 2025-03-03 11:21:11 +05:30
Neeraj Gupta
3ef8ece8c0 Refactor 2025-03-03 11:16:43 +05:30
Neeraj Gupta
24f8cf188a Refactor 2025-03-03 10:34:38 +05:30
Neeraj Gupta
233838da3e Merge branch 'main' into remote_db 2025-03-01 12:37:42 +05:30
Neeraj Gupta
06c4866c75 [mob] Refactor 2025-02-28 14:07:01 +05:30
Neeraj Gupta
fa71acf91a Merge branch 'main' into remote_db 2025-02-28 13:51:53 +05:30
Neeraj Gupta
81d40826b3 Merge remote-tracking branch 'origin/main' into remote_db 2025-02-28 12:59:40 +05:30
Neeraj Gupta
2a14b5e5a3 clean up 2025-02-28 12:59:26 +05:30
Neeraj Gupta
63bbca09f3 [mob] clean up 2025-02-28 11:39:20 +05:30
Neeraj Gupta
cbff68bc42 [server] Persist remote pull response 2025-02-28 10:56:15 +05:30
Neeraj Gupta
070ab80be9 [mob] Enable foreign_keys on remote db 2025-02-27 14:12:26 +05:30
Neeraj Gupta
0efbf407d3 [mob] table for collection_files and files 2025-02-27 14:11:52 +05:30
Neeraj Gupta
ab22b28695 Merge branch 'main' into remote_db 2025-02-26 16:13:26 +05:30
Neeraj Gupta
d3466d7efe [mob] Insert collection_files 2025-02-25 05:30:46 +05:30
Neeraj Gupta
5945f2aaad Merge branch 'main' into remote_db 2025-02-24 15:11:30 +05:30
Neeraj Gupta
4c79b9cb92 [mob] Pull remote diff using new diff models 2025-02-20 11:57:36 +05:30
Neeraj Gupta
4676c363d2 [mob] Refactor 2025-02-20 11:55:46 +05:30
Neeraj Gupta
f75807d8f0 [mob] New diff model 2025-02-20 11:24:02 +05:30
Neeraj Gupta
578541308a [mob] Refactor 2025-02-19 15:51:41 +05:30
Neeraj Gupta
30cded4d3d [mob][crypto] Sync stream decryption 2025-02-19 15:51:13 +05:30
Neeraj Gupta
be2aee6baa Merge branch 'main' into remote_db 2025-02-18 15:21:25 +05:30
Neeraj Gupta
fae23df6eb [mob] refactor 2025-02-17 14:37:09 +05:30
Neeraj Gupta
2a1f2aded1 [mob] Use remote db for collections 2025-02-14 21:23:01 +05:30
Neeraj Gupta
6e85d24286 [mob] Add db row mappers for collection 2025-02-14 21:23:01 +05:30
Neeraj Gupta
0bcc676e44 [mob] Add new collection model for remote 2025-02-14 21:23:01 +05:30
Neeraj Gupta
b5a9bab5c6 refactor 2025-02-14 21:23:01 +05:30
Neeraj Gupta
5849d14cd9 refactor 2025-02-14 21:23:01 +05:30
Neeraj Gupta
77cde87927 [mob] Create db for collections & collection_files 2025-02-14 21:23:01 +05:30
Neeraj Gupta
670d6e8470 [mob] Scaffold remote db 2025-02-14 21:23:01 +05:30
649 changed files with 17706 additions and 53704 deletions

View File

@@ -29,8 +29,6 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
persist-credentials: false
- name: Setup node and enable yarn caching
uses: actions/setup-node@v4
@@ -40,7 +38,7 @@ jobs:
cache-dependency-path: "web/yarn.lock"
- name: Install dependencies
run: yarn install --frozen-lockfile
run: yarn install
- name: Build ${{ inputs.app }}
run: yarn build:${{ inputs.app }}

View File

@@ -29,8 +29,6 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
persist-credentials: false
- name: Setup node and enable yarn caching
uses: actions/setup-node@v4
@@ -40,7 +38,7 @@ jobs:
cache-dependency-path: "web/yarn.lock"
- name: Install dependencies
run: yarn install --frozen-lockfile
run: yarn install
- name: Build ${{ inputs.app }}
run: yarn build:${{ inputs.app }}

View File

@@ -37,7 +37,6 @@ jobs:
uses: actions/checkout@v4
with:
ref: ${{ steps.select-branch.outputs.branch }}
persist-credentials: false
- name: Setup node and enable yarn caching
uses: actions/setup-node@v4
@@ -47,7 +46,7 @@ jobs:
cache-dependency-path: "web/yarn.lock"
- name: Install dependencies
run: yarn install --frozen-lockfile
run: yarn install
- name: Build photos
run: yarn build:photos

View File

@@ -33,8 +33,6 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
persist-credentials: false
- name: Setup node and enable yarn caching
uses: actions/setup-node@v4
@@ -44,15 +42,7 @@ jobs:
cache-dependency-path: "web/yarn.lock"
- name: Install dependencies
run: yarn install --frozen-lockfile
- name: Audit dependencies
run: |
yarn audit --level critical || exit_code=$?
if [[ $exit_code -ge 16 ]]; then
echo "::error::Yarn audit found critical issues"
exit 1
fi
run: yarn install
- name: Build photos
run: yarn build:photos

View File

@@ -24,8 +24,6 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
persist-credentials: false
- name: Setup node and enable yarn caching
uses: actions/setup-node@v4
@@ -34,14 +32,6 @@ jobs:
cache: "yarn"
cache-dependency-path: "web/yarn.lock"
- run: yarn install --frozen-lockfile
- run: yarn install
- run: yarn lint
- name: Audit dependencies
run: |
yarn audit --level critical || exit_code=$?
if [[ $exit_code -ge 16 ]]; then
echo "::error::Yarn audit found critical issues"
exit 1
fi

View File

@@ -48,11 +48,7 @@ See [docs/](docs/README.md) for how to edit these documents.
## Code contributions
If you'd like to contribute code, it is best to start small. Consider some well-scoped changes, say like adding more [custom icons to auth](mobile/apps/auth/docs/adding-icons.md), or fixing a specific bug. There is a (possibly outdated) list of tasks with the ["help wanted" or "good first issue"](<https://github.com/ente-io/ente/issues?q=state%3Aopen%20(label%3A%22good%20first%20issue%22%20OR%20label%3A%22help%20wanted%22%20)>) label too.
If you use any form of AI assistance, please include a co-author attribution in the commit for transparency.
In your PR, please include before / after screenshots, and clearly indicate the tests that you performed.
If you'd like to contribute code, it is best to start small. Consider some well-scoped changes, say like adding more [custom icons to auth](mobile/apps/auth/docs/adding-icons.md), or fixing a specific bug.
Code that changes the behaviour of the product might not get merged, at least not initially. The PR can serve as a discussion bed, but you might find it easier to just start a discussion instead, or post your perspective in the (likely) existing thread about the behaviour change or new feature you wish for.

View File

@@ -44,7 +44,7 @@ The first step is to let Ente know about the domain or subdomain you wish to use
> [!WARNING]
>
> Currently (Sep 2025) the ability to link a custom domain is only present in Ente's web app, [web.ente.io](https://web.ente.io).
> Currently (Aug 2025) the ability to link a custom domain is only present in Ente's web app, [web.ente.io](https://web.ente.io). It will come to Ente mobile and desktop when their next versions get released.
Head over to Preferences > Custom domains, in the domain field enter "pics.example.org" (replace with your subdomain) and press "Save". That's it. The linking is done.
@@ -94,7 +94,7 @@ Using is trivial. When you go to an album's sharing options and copy the link to
> [!WARNING]
>
> Currently (Sep 2025) the ability to automatically substitute your custom domain is present in Ente's web and mobile apps, but not in the desktop app (The next desktop version to be released will have that ability too).
> Currently (Aug 2025) the ability to automatically substitute your custom domain is only present in Ente's web app, [web.ente.io](https://web.ente.io). It will come to Ente mobile and desktop when their next versions get released.
## Unsetting
@@ -103,7 +103,3 @@ To stop using your custom domain, we need to undo the two steps we did during se
1. Unlink your domain in Ente. This can be done just by going to Preferences > Custom Domains, clearing the value in the "Domain" input and pressing "Update".
2. Remove the CNAME record you added during setup in your DNS provider.
## Implementation
Our engineers also wrote [explainer](https://ente.io/blog/custom-domains/) of how this works behind the scenes.

View File

@@ -6,7 +6,7 @@ description: Removing duplicates photos using Ente Photos
# Deduplicate
Ente performs two different duplicate detections: one during uploads, and one
that can be manually run afterwards to remove duplicates and very similar files across albums.
that can be manually run afterwards to remove duplicates across albums.
## During uploads
@@ -16,7 +16,7 @@ When uploading, Ente will ignore exact duplicate files. This allows you to
resume interrupted uploads, or drag and drop the same folder, or reinstall the
app, and expect Ente to automatically skip duplicates and only add new files.
The duplicate detection works slightly differently on each platform, to cater to
The duplicate detection works slightly different on each platform, to cater to
the platform's nuances.
#### Mobile
@@ -48,7 +48,7 @@ to album", and the actual files are not re-uploaded.
## Manual deduplication
Ente provides a tool for manual de-duplication in _Settings → Backup → Free up space →
Ente also provides a tool for manual de-duplication in _Settings → Backup →
Remove duplicates_. This is useful if you have an existing library with
duplicates across different albums, but wish to keep only one copy.
@@ -57,13 +57,6 @@ single copy, and add symlinks to this copy within all existing albums. So your
existing album structure remains unchanged, while the space consumed by the
duplicate data is freed up.
## Filtering similar images
Ente also provides a tool for manual removal of images that are similar, but not the exact same, using our private ML. This feature can be found in _Settings → Backup → Free up space →
Similar images_. This is useful if you've taken a lot of similar photos, potentiall even in different albums, and want to keep only the best ones.
During this filtering process you can choose which photos to keep and which to delete for each set of similar images. Ente will then automatically add symlinks for the kept photos to any albums that only had the deleted images. This way you can easily prune similar images, without worrying about accidentally removing the best ones from a certain album.
## Adding to Ente album creates symlinks
Note that once a file is in Ente, adding it to another Ente album will create a

View File

@@ -89,7 +89,7 @@ cast.ente.yourdomain.tld {
Reload Caddy for changes to take effect.
```shell
sudo systemctl reload caddy
sudo systemctl caddy reload
```
## Step 4: Verify the setup

View File

@@ -8,7 +8,6 @@
.buildlog/
.history
.svn/
android/app/build/
# Editors
.vscode/

View File

@@ -527,24 +527,5 @@
"errorInvalidQRCodeBody": "The scanned QR code is not a valid 2FA account.",
"errorNoQRCode": "No QR code found",
"errorGenericTitle": "An Error Occurred",
"errorGenericBody": "An unexpected error occurred while importing.",
"localBackupSettingsTitle": "Local backup",
"localBackupSidebarTitle": "Local backup",
"enableAutomaticBackups": "Enable automatic backups",
"backupDescription": "This will automatically backup your data to an on-device location. Backups are updated whenever entries are added, edited or deleted",
"currentLocation": "Current backup location:",
"securityNotice": "Security notice",
"backupSecurityNotice": "This encrypted backup holds your 2FA keys. If lost, you may not be able to recover your accounts. Keep it safe!",
"locationUpdatedAndBackupCreated": "Location updated and initial backup created!",
"initialBackupCreated": "Initial backup created!",
"passwordTooShort": "Password must be at least 8 characters long.",
"noDefaultBackupFolder": "Could not create default backup folder.",
"backupLocationChoiceDescription": "Where do you want to save your backups?",
"chooseBackupLocation": "Choose a backup location",
"loadDefaultLocation": "Loading default location...",
"couldNotDetermineLocation":"Could not determine location...",
"saveAction":"Save",
"saveBackup":"Save backup",
"changeLocation": "Change location",
"changeCurrentLocation": "Change current location"
"errorGenericBody": "An unexpected error occurred while importing."
}

View File

@@ -1,177 +0,0 @@
import 'dart:convert';
import 'dart:io';
import 'package:ente_auth/models/export/ente.dart';
import 'package:ente_auth/store/code_store.dart';
import 'package:ente_crypto_dart/ente_crypto_dart.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:intl/intl.dart'; //for time based file naming
import 'package:logging/logging.dart';
import 'package:shared_preferences/shared_preferences.dart';
//we gonn change
class LocalBackupService {
final _logger = Logger('LocalBackupService');
static final LocalBackupService instance =
LocalBackupService._privateConstructor();
LocalBackupService._privateConstructor();
static const int _maxBackups = 2;
// to create an encrypted backup file if the toggle is on
Future<void> triggerAutomaticBackup() async {
try {
final prefs = await SharedPreferences.getInstance();
final isEnabled = prefs.getBool('isAutoBackupEnabled') ?? false;
if (!isEnabled) {
return;
}
final backupPath = prefs.getString('autoBackupPath');
if (backupPath == null) {
return;
}
const storage = FlutterSecureStorage();
final password = await storage.read(key: 'autoBackupPassword');
if (password == null || password.isEmpty) {
_logger.warning("Automatic backup skipped: password not set.");
return;
}
_logger.info("Change detected, triggering automatic encrypted backup...");
String rawContent = await CodeStore.instance.getCodesForExport();
List<String> lines = rawContent.split('\n');
List<String> cleanedLines = [];
for (String line in lines) {
if (line.trim().isEmpty) continue;
String cleanUrl;
if (line.startsWith('"') && line.endsWith('"')) {
cleanUrl = jsonDecode(line);
}
else {
cleanUrl = line;
}
cleanedLines.add(cleanUrl);
}
final plainTextContent = cleanedLines.join('\n');
if (plainTextContent.trim().isEmpty) {
return;
}
final kekSalt = CryptoUtil.getSaltToDeriveKey();
final derivedKeyResult = await CryptoUtil.deriveSensitiveKey(
utf8.encode(password),
kekSalt,
);
final encResult = await CryptoUtil.encryptData(
utf8.encode(plainTextContent),
derivedKeyResult.key,
);
final encContent = CryptoUtil.bin2base64(encResult.encryptedData!);
final encNonce = CryptoUtil.bin2base64(encResult.header!);
final EnteAuthExport data = EnteAuthExport(
version: 1,
encryptedData: encContent,
encryptionNonce: encNonce,
kdfParams: KDFParams(
memLimit: derivedKeyResult.memLimit,
opsLimit: derivedKeyResult.opsLimit,
salt: CryptoUtil.bin2base64(kekSalt),
),
);
final encryptedJson = jsonEncode(data.toJson());
final now = DateTime.now();
final formatter = DateFormat('yyyy-MM-dd_HH-mm-ss');
final formattedDate = formatter.format(now);
final fileName = 'ente-auth-auto-backup-$formattedDate.json';
final filePath = '$backupPath/$fileName';
final backupFile = File(filePath);
await backupFile.writeAsString(encryptedJson);
await _manageOldBackups(backupPath);
_logger.info('Automatic encrypted backup successful! Saved to: $filePath');
} catch (e, s) {
_logger.severe('Silent error during automatic backup', e, s);
}
}
Future<void> _manageOldBackups(String backupPath) async {
try {
_logger.info("Checking for old backups to clean up...");
final directory = Directory(backupPath);
// fetch all filenames in the folder, filter out ente backup files
final files = directory.listSync()
.where((entity) =>
entity is File &&
entity.path.split('/').last.startsWith('ente-auth-auto-backup-'),)
.map((entity) => entity as File)
.toList();
// sort the fetched files in asc order (oldest first because the name is a timestamp)
files.sort((a, b) => a.path.compareTo(b.path));
// if we have more files than our limit, delete the oldest ones (current limit=_maxBackups)
while (files.length > _maxBackups) {
// remove the oldest file (at index 0) from the list
final fileToDelete = files.removeAt(0);
// and delete it from the device's storage..
await fileToDelete.delete();
_logger.info('Deleted old backup: ${fileToDelete.path}');
}
_logger.info('Backup count is now ${files.length}. Cleanup complete.');
} catch (e, s) {
_logger.severe('Error during old backup cleanup', e, s);
}
}
Future<void> deleteAllBackupsIn(String path) async {
try {
_logger.info("Deleting all backups in old location: $path");
final directory = Directory(path);
if (!await directory.exists()) {
_logger.warning("Old backup directory not found. Nothing to delete.");
return;
}
final files = directory.listSync()
.where((entity) =>
entity is File &&
entity.path.split('/').last.startsWith('ente-auth-auto-backup-'),)
.map((entity) => entity as File)
.toList();
if (files.isEmpty) {
_logger.info("No old backup files found to delete.");
return;
}
for (final file in files) {
await file.delete();
_logger.info('Deleted: ${file.path}');
}
_logger.info("Successfully cleaned up old backup location.");
} catch (e, s) {
_logger.severe('Error during full backup cleanup of old directory', e, s);
}
}
}

View File

@@ -7,7 +7,6 @@ import 'package:ente_auth/events/codes_updated_event.dart';
import 'package:ente_auth/models/authenticator/entity_result.dart';
import 'package:ente_auth/models/code.dart';
import 'package:ente_auth/services/authenticator_service.dart';
import 'package:ente_auth/services/local_backup_service.dart';
import 'package:ente_auth/store/offline_authenticator_db.dart';
import 'package:ente_events/event_bus.dart';
import 'package:logging/logging.dart';
@@ -65,27 +64,6 @@ class CodeStore {
return true;
}
Future<void> updateCode(Code originalCode, Code updatedCode, {bool shouldSync = true}) async {
if (updatedCode.generatedID == null) return;
await _authenticatorService.updateEntry(
updatedCode.generatedID!,
updatedCode.toOTPAuthUrlFormat(),
shouldSync,
_authenticatorService.getAccountMode(),
);
Bus.instance.fire(CodesUpdatedEvent());
final bool isMajorChange = originalCode.issuer != updatedCode.issuer ||
originalCode.account != updatedCode.account ||
originalCode.secret != updatedCode.secret ||
originalCode.display.note != updatedCode.display.note;
if (isMajorChange) {
LocalBackupService.instance.triggerAutomaticBackup().ignore();
}
}
Future<List<Code>> getAllCodes({
AccountMode? accountMode,
bool sortCodes = true,
@@ -117,6 +95,7 @@ class CodeStore {
}
if (sortCodes) {
// sort codes by issuer,account
codes.sort((firstCode, secondCode) {
if (secondCode.isPinned && !firstCode.isPinned) return 1;
if (!secondCode.isPinned && firstCode.isPinned) return -1;
@@ -141,17 +120,13 @@ class CodeStore {
bool shouldSync = true,
AccountMode? accountMode,
List<Code>? existingAllCodes,
bool isFrequencyOrRecencyUpdate = false,
}) async {
final mode = accountMode ?? _authenticatorService.getAccountMode();
final allCodes = existingAllCodes ?? (await getAllCodes(accountMode: mode));
bool isExistingCode = false;
bool hasSameCode = false;
for (final existingCode in allCodes) {
if (existingCode.hasError) continue;
if (code.generatedID != null &&
existingCode.generatedID == code.generatedID) {
isExistingCode = true;
@@ -173,9 +148,6 @@ class CodeStore {
shouldSync,
mode,
);
if (!isFrequencyOrRecencyUpdate) {
LocalBackupService.instance.triggerAutomaticBackup().ignore();
}
} else {
result = AddResult.newCode;
code.generatedID = await _authenticatorService.addEntry(
@@ -183,7 +155,6 @@ class CodeStore {
shouldSync,
mode,
);
LocalBackupService.instance.triggerAutomaticBackup().ignore();
}
Bus.instance.fire(CodesUpdatedEvent());
return result;
@@ -193,7 +164,6 @@ class CodeStore {
final mode = accountMode ?? _authenticatorService.getAccountMode();
await _authenticatorService.deleteEntry(code.generatedID!, mode);
Bus.instance.fire(CodesUpdatedEvent());
LocalBackupService.instance.triggerAutomaticBackup().ignore();
}
bool _isOfflineImportRunning = false;
@@ -244,6 +214,7 @@ class CodeStore {
'importingCode: genID ${eachCode.generatedID} & isAlreadyPresent $alreadyPresent',
);
if (!alreadyPresent) {
// Avoid conflict with generatedID of online codes
eachCode.generatedID = null;
final AddResult result = await CodeStore.instance.addCode(
eachCode,
@@ -265,21 +236,10 @@ class CodeStore {
_isOfflineImportRunning = false;
}
}
Future<String> getCodesForExport() async {
final allCodes = await getAllCodes(sortCodes: false);
String data = "";
for (final code in allCodes) {
if (code.hasError) continue;
data += "${code.toOTPAuthUrlFormat()}\n";
}
return data;
}
}
enum AddResult {
newCode,
duplicate,
updateCode,
}
}

View File

@@ -478,7 +478,7 @@ class _CodeWidgetState extends State<CodeWidget> {
_getCurrentOTP(),
confirmationMessage: context.l10n.copiedToClipboard,
);
_updateCodeMetadata().ignore();
_udateCodeMetadata().ignore();
}
void _copyNextToClipboard() {
@@ -486,10 +486,10 @@ class _CodeWidgetState extends State<CodeWidget> {
_getNextTotp(),
confirmationMessage: context.l10n.copiedNextToClipboard,
);
_updateCodeMetadata().ignore();
_udateCodeMetadata().ignore();
}
Future<void> _updateCodeMetadata() async {
Future<void> _udateCodeMetadata() async {
if (widget.sortKey == null) return;
Future.delayed(const Duration(milliseconds: 100), () {
if (mounted) {
@@ -502,7 +502,7 @@ class _CodeWidgetState extends State<CodeWidget> {
lastUsedAt: DateTime.now().microsecondsSinceEpoch,
),
);
unawaited(CodeStore.instance.addCode(code, isFrequencyOrRecencyUpdate: true));
unawaited(CodeStore.instance.addCode(code));
}
}
});

View File

@@ -94,7 +94,6 @@ class _HomePageState extends State<HomePage> {
@override
void initState() {
super.initState();
_codeSortKey = PreferenceService.instance.codeSortKey();
_textController.addListener(_applyFilteringAndRefresh);
_loadCodes();
@@ -154,7 +153,6 @@ class _HomePageState extends State<HomePage> {
}
void _loadCodes() {
debugPrint("[HOME_DEBUG] _loadCodes triggered!");
CodeStore.instance.getAllCodes().then((codes) {
_allCodes = codes;
hasTrashedCodes = false;
@@ -384,8 +382,7 @@ class _HomePageState extends State<HomePage> {
final bool shouldShowLockScreen =
await LockScreenSettings.instance.shouldShowLockScreen();
if (shouldShowLockScreen) {
// Manual lock: do not auto-prompt Touch ID; wait for user tap
await AppLock.of(context)!.showManualLockScreen();
await AppLock.of(context)!.showLockScreen();
} else {
await showDialogWidget(
context: context,
@@ -819,4 +816,4 @@ class _HomePageState extends State<HomePage> {
],
);
}
}
}

View File

@@ -10,7 +10,6 @@ import 'package:ente_auth/ui/settings/common_settings.dart';
import 'package:ente_auth/ui/settings/data/duplicate_code_page.dart';
import 'package:ente_auth/ui/settings/data/export_widget.dart';
import 'package:ente_auth/ui/settings/data/import_page.dart';
import 'package:ente_auth/ui/settings/data/local_backup_settings_page.dart'; //for local backup
import 'package:ente_auth/utils/dialog_util.dart';
import 'package:ente_auth/utils/navigation_util.dart';
import 'package:flutter/material.dart';
@@ -30,10 +29,6 @@ class DataSectionWidget extends StatelessWidget {
);
}
Future<void> _handleLocalBackupClick(BuildContext context) async {
await routeToPage(context, const LocalBackupSettingsPage());
}
Column _getSectionOptions(BuildContext context) {
final l10n = context.l10n;
List<Widget> children = [];
@@ -91,21 +86,10 @@ class DataSectionWidget extends StatelessWidget {
);
},
),
MenuItemWidget(
captionedTextWidget: CaptionedTextWidget(
title: l10n.localBackupSidebarTitle,
),
pressedColor: getEnteColorScheme(context).fillFaint,
trailingIcon: Icons.chevron_right_outlined,
trailingIconIsMuted: true,
onTap: () async {
await _handleLocalBackupClick(context);
},
),
sectionOptionSpacing,
]);
return Column(
children: children,
);
}
}
}

View File

@@ -153,4 +153,4 @@ Future<void> _pickEnteJsonFile(BuildContext context) async {
context.l10n.importFailureDescNew,
);
}
}
}

View File

@@ -1,458 +0,0 @@
import 'dart:io';
import 'package:ente_auth/ente_theme_data.dart';
import 'package:ente_auth/l10n/l10n.dart';
import 'package:ente_auth/services/local_backup_service.dart';
import 'package:ente_auth/theme/ente_theme.dart';
import 'package:ente_auth/ui/components/buttons/button_widget.dart';
import 'package:ente_auth/ui/components/dialog_widget.dart';
import 'package:ente_auth/ui/components/models/button_result.dart';
import 'package:ente_auth/ui/components/models/button_type.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:path_provider/path_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
class LocalBackupSettingsPage extends StatefulWidget {
const LocalBackupSettingsPage({super.key});
@override
State<LocalBackupSettingsPage> createState() =>
_LocalBackupSettingsPageState();
}
class _LocalBackupSettingsPageState extends State<LocalBackupSettingsPage> {
bool _isBackupEnabled = false;
String? _backupPath;
@override
void initState() {
super.initState();
_loadSettings();
}
// to load the saved settings from SharedPreferences when the page opens.
Future<void> _loadSettings() async {
final prefs = await SharedPreferences.getInstance();
setState(() {
_isBackupEnabled = prefs.getBool('isAutoBackupEnabled') ?? false;
_backupPath = prefs.getString('autoBackupPath');
});
}
Future<String?> _showCustomPasswordDialog() async {
final l10n = context.l10n;
final textController = TextEditingController();
// state variable to track password visibility
bool isPasswordHidden = true;
return showDialog<String>(
context: context,
barrierDismissible: false,
builder: (context) {
return StatefulBuilder(
builder: (context, setState) {
return AlertDialog(
title: Text(l10n.setPasswordTitle, style: getEnteTextTheme(context).largeBold),
content: TextField(
controller: textController,
autofocus: true,
obscureText: isPasswordHidden,
decoration: InputDecoration(
hintText: l10n.enterPassword,
hintStyle: getEnteTextTheme(context).mini,
suffixIcon: IconButton(
icon: Icon(
isPasswordHidden ? Icons.visibility_off : Icons.visibility,
),
onPressed: () {
setState(() {
isPasswordHidden = !isPasswordHidden;
});
},
),
),
onChanged: (text) => setState(() {}),
),
actions: [
Row(
children: [
Expanded(
child: ButtonWidget(
buttonType: ButtonType.secondary,
labelText: l10n.cancel,
onTap: () async => Navigator.of(context).pop(null),
),
),
const SizedBox(width: 8),
Expanded(
child: ButtonWidget(
buttonType: ButtonType.primary,
labelText: l10n.saveAction,
isDisabled: textController.text.isEmpty,
onTap: () async => Navigator.of(context).pop(textController.text),
),
),
],
),
],
);
},
);
},
);
}
Future<ButtonResult?> _showLocationChoiceDialog({required String displayPath}) async {
final l10n = context.l10n;
final dialogBody =
'${l10n.backupLocationChoiceDescription}\n\nSelected: ${_simplifyPath(displayPath)}';
final result = await showDialogWidget(
title: l10n.chooseBackupLocation,
context: context,
body: dialogBody,
buttons: [
ButtonWidget(
buttonType: ButtonType.primary,
labelText: l10n.saveBackup,
isInAlert: true,
buttonSize: ButtonSize.large,
buttonAction: ButtonAction.first,
),
ButtonWidget(
buttonType: ButtonType.secondary,
labelText: l10n.changeLocation,
isInAlert: true,
buttonSize: ButtonSize.large,
buttonAction: ButtonAction.second,
),
ButtonWidget(
buttonType: ButtonType.secondary,
labelText: l10n.cancel,
isInAlert: true,
buttonSize: ButtonSize.large,
buttonAction: ButtonAction.cancel,
),
],
);
return result;
}
Future<bool> _handleLocationSetup() async {
String currentPath = _backupPath ?? await _getDefaultBackupPath();
while (true) {
final result = await _showLocationChoiceDialog(displayPath: currentPath);
if (result?.action == ButtonAction.first) {
final prefs = await SharedPreferences.getInstance();
try {
await Directory(currentPath).create(recursive: true);
await prefs.setString('autoBackupPath', currentPath);
setState(() {
_backupPath = currentPath;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.initialBackupCreated)),
);
return true;
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.noDefaultBackupFolder)),
);
return false;
}
}
else if (result?.action == ButtonAction.second) {
final newPath = await FilePicker.platform.getDirectoryPath();
if (newPath != null) {
currentPath = newPath;
}
}
else {
return false;
}
}
}
Future<String> _getDefaultBackupPath() async {
if (Platform.isAndroid) {
Directory? externalDir = await getExternalStorageDirectory();
if (externalDir != null) {
String storagePath = externalDir.path.split('/Android')[0];
return '$storagePath/Download/EnteAuthBackups';
}
}
Directory? dir = await getDownloadsDirectory();
dir ??= await getApplicationDocumentsDirectory();
return '${dir.path}/EnteAuthBackups';
}
String _simplifyPath(String fullPath) { //takes a file path string and shortens it if it matches the common Android root path.
const rootToRemove = '/storage/emulated/0/';
if (fullPath.startsWith(rootToRemove)) {
return fullPath.substring(rootToRemove.length);
}
return fullPath;
}
// opens directory picker
Future<bool> _pickAndSaveBackupLocation({String? successMessage}) async {
final prefs = await SharedPreferences.getInstance();
final l10n = context.l10n;
String? directoryPath = await FilePicker.platform.getDirectoryPath();
if (directoryPath != null) {
await prefs.setString('autoBackupPath', directoryPath);
// we only set the state and create the backup if a path was chosen
setState(() {
_backupPath = directoryPath;
});
await LocalBackupService.instance.triggerAutomaticBackup();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(successMessage ?? l10n.locationUpdatedAndBackupCreated),
),
);
return true;
}
return false; //user cancelled the file picker
}
Future<void> _showSetPasswordDialog() async {
final String? password = await _showCustomPasswordDialog();
if (password == null) {
setState(() {
_isBackupEnabled = false;
});
return;
}
if (password.length < 8) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.passwordTooShort),
),
);
setState(() {
_isBackupEnabled = false;
});
return;
}
const storage = FlutterSecureStorage();
await storage.write(key: 'autoBackupPassword', value: password);
SchedulerBinding.instance.addPostFrameCallback((_) async {
final bool setupCompleted = await _handleLocationSetup();
if (!mounted) return;
if (setupCompleted) {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('isAutoBackupEnabled', true);
setState(() {
_isBackupEnabled = true;
});
await LocalBackupService.instance.triggerAutomaticBackup();
} else {
setState(() {
_isBackupEnabled = false;
});
}
});
}
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
return Scaffold(
appBar: AppBar(
title: Text(l10n.localBackupSettingsTitle), //text shown on appbar
),
body: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(
l10n.enableAutomaticBackups, //toggle text
style: getEnteTextTheme(context).largeBold,
),
),
Switch.adaptive(
value: _isBackupEnabled,
activeColor: Theme.of(context)
.colorScheme
.enteTheme
.colorScheme
.primary400,
activeTrackColor: Theme.of(context)
.colorScheme
.enteTheme
.colorScheme
.primary300,
inactiveTrackColor: Theme.of(context)
.colorScheme
.enteTheme
.colorScheme
.fillMuted,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
onChanged: (value) async {
final prefs = await SharedPreferences.getInstance();
if (value == true) {
//when toggle is ON, show password dialog
await _showSetPasswordDialog();
} else {
await prefs.setBool('isAutoBackupEnabled', false);
setState(() {
_isBackupEnabled = false;
});
}
},
),
],
),
Padding(
padding: const EdgeInsets.only(top: 10.0),
child: Text(
l10n.backupDescription, //text below toggle
style: getEnteTextTheme(context).mini,
),
),
const SizedBox(height: 20),
Opacity(
opacity: _isBackupEnabled ? 1.0 : 0.4,
child: IgnorePointer(
ignoring: !_isBackupEnabled,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.currentLocation, //shows current backup location
style: getEnteTextTheme(context).body,
),
const SizedBox(height: 4),
if (_backupPath != null)
Text(
_simplifyPath(_backupPath!),
style: getEnteTextTheme(context).small,
)
else
FutureBuilder<String>(
future: _getDefaultBackupPath(),
builder: (context, snapshot) {
if (snapshot.connectionState ==
ConnectionState.waiting) {
return Text(
l10n.loadDefaultLocation,
style: getEnteTextTheme(context)
.small
.copyWith(color: Colors.grey),
);
} else if (snapshot.hasError) {
return Text(
l10n.couldNotDetermineLocation,
style: getEnteTextTheme(context)
.small
.copyWith(color: Colors.red),
);
} else {
return Text(
_simplifyPath(snapshot.data ?? ''),
style: getEnteTextTheme(context)
.small
.copyWith(color: Colors.grey),
);
}
},
),
const SizedBox(height: 30),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () => _pickAndSaveBackupLocation(),
child: Text(l10n.changeCurrentLocation),
),
),
],
),
const SizedBox(height: 20),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.orange.withAlpha(26),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: Colors.orange.withAlpha(77),
width: 1,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(
Icons.security_outlined,
color: Colors.orange,
size: 20,
),
const SizedBox(width: 8),
Text(
l10n.securityNotice, //security notice title
style: getEnteTextTheme(context)
.smallBold
.copyWith(
color: Colors.orange,
),
),
],
),
const SizedBox(height: 8),
Text(
l10n.backupSecurityNotice, //security notice description
style: getEnteTextTheme(context).mini.copyWith(
color: Colors.orange.shade700,
),
),
],
),
),
],
),
),
),
],
),
),
),
);
}
}

View File

@@ -414,7 +414,6 @@ Future<dynamic> showTextInputDialog(
bool alwaysShowSuccessState = false,
bool isPasswordInput = false,
bool useRootNavigator = false,
VoidCallback? onCancel,
}) {
return showDialog(
barrierColor: backdropFaintDark,

View File

@@ -1153,9 +1153,9 @@ packages:
dependency: "direct main"
description:
path: "."
ref: v2-only
resolved-ref: "0cdfeed654d79636eff0c57110f3f6ad5801ba2f"
url: "https://github.com/ente-io/move_to_background.git"
ref: "91e4d1a9c55b28bf93425d1f12faf410efc1e48d"
resolved-ref: "91e4d1a9c55b28bf93425d1f12faf410efc1e48d"
url: "https://github.com/Sayegh7/move_to_background"
source: git
version: "1.0.2"
native_dio_adapter:

View File

@@ -95,10 +95,10 @@ dependencies:
local_auth_darwin: ^1.2.2
logging: ^1.0.1
modal_bottom_sheet: ^3.0.0
move_to_background: # no updates in git, replace package
move_to_background: # no package updates on pub.dev
git:
url: https://github.com/ente-io/move_to_background.git
ref: v2-only
url: https://github.com/Sayegh7/move_to_background
ref: 91e4d1a9c55b28bf93425d1f12faf410efc1e48d
native_dio_adapter: ^1.4.0
otp: ^3.1.1
package_info_plus: ^8.0.2

View File

@@ -54,5 +54,4 @@
<data android:mimeType="text/plain"/>
</intent>
</queries>
<uses-permission android:name="android.permission.USE_BIOMETRIC"/>
</manifest>

View File

@@ -1,6 +1,5 @@
package io.ente.locker
import io.flutter.embedding.android.FlutterFragmentActivity
import io.flutter.embedding.android.FlutterActivity
class MainActivity: FlutterFragmentActivity() {
}
class MainActivity: FlutterActivity()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

View File

@@ -1,20 +0,0 @@
<svg width="200" height="200" viewBox="0 0 200 200" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_34053_111310)">
<path d="M170 66.7593C170 71.8084 169.042 76.732 167.151 81.3933C165.192 86.2233 162.316 90.5536 158.606 94.265L147.36 105.511L151.657 109.806C152.962 111.112 152.26 113.347 150.441 113.671L128.349 117.598C126.785 117.875 125.423 116.513 125.7 114.949L129.628 92.8595C129.952 91.0413 132.187 90.3393 133.492 91.6442L137.12 95.2717L148.366 84.0261C152.978 79.4144 155.519 73.282 155.519 66.7609C155.519 60.2383 152.978 54.105 148.365 49.4941C143.753 44.8815 137.621 42.3422 131.097 42.3422C124.574 42.3422 118.442 44.8815 113.83 49.4941L107.387 55.936C107.383 55.9064 107.378 55.8784 107.373 55.8488L104.299 38.5628C107.848 35.1847 111.936 32.5447 116.463 30.7097C121.123 28.818 126.048 27.8594 131.097 27.8594C136.146 27.8594 141.07 28.818 145.732 30.7089C150.564 32.667 154.894 35.5421 158.606 39.2528C162.317 42.9643 165.192 47.2946 167.151 52.1246C169.041 56.7859 169.999 61.711 170 66.7593Z" fill="#4AAF3C"/>
<path d="M133.814 119.087L105.12 147.779C103.762 149.137 101.92 149.9 99.9998 149.9C98.0791 149.9 96.2375 149.137 94.879 147.779L70.7775 123.678L67.503 126.953C66.1972 128.259 63.9623 127.556 63.6392 125.737L59.7107 103.646C59.4332 102.083 60.7966 100.72 62.3598 100.997L84.4519 104.925C86.2702 105.249 86.9731 107.483 85.6673 108.789L81.0183 113.438L100.001 132.42L124.507 107.914L123.318 114.601C123.053 116.096 123.535 117.63 124.609 118.703C125.684 119.778 127.217 120.259 128.712 119.994L133.814 119.087H133.814Z" fill="#4AAF3C"/>
<path d="M102.359 58.8607L80.2668 54.9325C78.4485 54.6095 77.7456 52.3748 79.0514 51.0692L83.1799 46.9412C79.0498 43.9533 74.1009 42.3406 68.9034 42.3406C62.3808 42.3406 56.2477 44.88 51.6363 49.4925C47.0232 54.1042 44.4828 60.2359 44.4828 66.7585C44.4828 73.2812 47.0232 79.4128 51.6363 84.0246L66.9435 99.3301L62.7415 98.5834C61.2462 98.3179 59.7125 98.8 58.6386 99.8738C57.5647 100.948 57.0825 102.482 57.348 103.977L58.697 111.564L41.3955 94.2635C37.6836 90.552 34.8089 86.2217 32.8499 81.3917C30.9588 76.7312 30 71.8076 30 66.7593C30 61.711 30.9588 56.7867 32.8491 52.1254C34.8081 47.2946 37.6836 42.9643 41.3947 39.2536C45.1057 35.5421 49.4365 32.6678 54.267 30.7097C58.9296 28.818 63.8537 27.8594 68.9034 27.8594C73.953 27.8594 78.8771 28.818 83.5389 30.7081C87.158 32.1753 90.4956 34.1565 93.503 36.6183L97.2157 32.9061C98.5215 31.6004 100.756 32.3032 101.079 34.1214L105.007 56.211C105.286 57.7741 103.922 59.1381 102.359 58.8607Z" fill="#4AAF3C"/>
</g>
<g filter="url(#filter0_f_34053_111310)">
<ellipse cx="102.576" cy="170.536" rx="27.5758" ry="1.59085" fill="#1CA609" fill-opacity="0.5"/>
</g>
<defs>
<filter id="filter0_f_34053_111310" x="70.5" y="164.445" width="64.1515" height="12.1797" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feGaussianBlur stdDeviation="2.25" result="effect1_foregroundBlur_34053_111310"/>
</filter>
<clipPath id="clip0_34053_111310">
<rect width="140" height="122" fill="white" transform="translate(30 27.8828)"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 3.2 KiB

View File

@@ -1,20 +0,0 @@
<svg width="200" height="200" viewBox="0 0 200 200" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_34053_111286)">
<path d="M170 66.7593C170 71.8084 169.042 76.732 167.151 81.3933C165.192 86.2233 162.316 90.5536 158.606 94.265L147.36 105.511L151.657 109.806C152.962 111.112 152.26 113.347 150.441 113.671L128.349 117.598C126.785 117.875 125.423 116.513 125.7 114.949L129.628 92.8594C129.952 91.0413 132.187 90.3393 133.492 91.6441L137.12 95.2717L148.366 84.0261C152.978 79.4144 155.519 73.2819 155.519 66.7609C155.519 60.2383 152.978 54.105 148.365 49.4941C143.753 44.8815 137.621 42.3422 131.097 42.3422C124.574 42.3422 118.442 44.8815 113.83 49.4941L107.387 55.936C107.383 55.9064 107.378 55.8784 107.373 55.8488L104.299 38.5628C107.848 35.1847 111.936 32.5447 116.463 30.7097C121.123 28.818 126.048 27.8594 131.097 27.8594C136.146 27.8594 141.07 28.818 145.732 30.7089C150.564 32.667 154.894 35.5421 158.606 39.2528C162.317 42.9643 165.192 47.2946 167.151 52.1246C169.041 56.7859 169.999 61.7102 170 66.7585V66.7593Z" fill="#4AAF3C"/>
<path d="M133.814 119.087L105.12 147.779C103.762 149.137 101.92 149.9 99.9998 149.9C98.0791 149.9 96.2375 149.137 94.879 147.779L70.7775 123.678L67.503 126.953C66.1972 128.259 63.9623 127.556 63.6392 125.737L59.7107 103.646C59.4332 102.083 60.7966 100.72 62.3598 100.997L84.4519 104.925C86.2702 105.249 86.9731 107.483 85.6673 108.789L81.0183 113.438L100.001 132.42L124.507 107.914L123.318 114.601C123.053 116.096 123.535 117.63 124.609 118.703C125.684 119.778 127.217 120.259 128.712 119.994L133.814 119.087H133.814Z" fill="#4AAF3C"/>
<path d="M102.359 58.8607L80.2668 54.9325C78.4485 54.6095 77.7456 52.3748 79.0514 51.0692L83.1799 46.9412C79.0498 43.9533 74.1009 42.3406 68.9034 42.3406C62.3808 42.3406 56.2477 44.88 51.6363 49.4925C47.0232 54.1042 44.4828 60.2359 44.4828 66.7585C44.4828 73.2812 47.0232 79.4128 51.6363 84.0246L66.9435 99.3301L62.7415 98.5834C61.2462 98.3179 59.7125 98.8 58.6386 99.8738C57.5647 100.948 57.0825 102.482 57.348 103.977L58.697 111.564L41.3955 94.2635C37.6836 90.552 34.8089 86.2217 32.8499 81.3917C30.9588 76.7312 30 71.8076 30 66.7593C30 61.711 30.9588 56.7867 32.8491 52.1254C34.8081 47.2946 37.6836 42.9643 41.3947 39.2536C45.1057 35.5421 49.4365 32.6678 54.267 30.7097C58.9296 28.818 63.8537 27.8594 68.9034 27.8594C73.953 27.8594 78.8771 28.818 83.5389 30.7081C87.158 32.1753 90.4957 34.1565 93.503 36.6183L97.2157 32.9061C98.5215 31.6004 100.756 32.3032 101.079 34.1214L105.007 56.211C105.286 57.7741 103.922 59.1373 102.359 58.8599V58.8607Z" fill="#4AAF3C"/>
</g>
<g filter="url(#filter0_f_34053_111286)">
<ellipse cx="101.576" cy="170.536" rx="27.5758" ry="1.59085" fill="#E1E1E1" fill-opacity="0.5"/>
</g>
<defs>
<filter id="filter0_f_34053_111286" x="69.5" y="164.445" width="64.1515" height="12.1797" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feGaussianBlur stdDeviation="2.25" result="effect1_foregroundBlur_34053_111286"/>
</filter>
<clipPath id="clip0_34053_111286">
<rect width="140" height="122" fill="white" transform="translate(30 27.8828)"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

View File

@@ -188,37 +188,37 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/url_launcher_ios/ios"
SPEC CHECKSUMS:
app_links: 76b66b60cc809390ca1ad69bfd66b998d2387ac7
cupertino_http: 94ac07f5ff090b8effa6c5e2c47871d48ab7c86c
device_info_plus: 335f3ce08d2e174b9fdc3db3db0f4e3b1f66bd89
app_links: f3e17e4ee5e357b39d8b95290a9b2c299fca71c6
cupertino_http: 947a233f40cfea55167a49f2facc18434ea117ba
device_info_plus: c6fb39579d0f423935b0c9ce7ee2f44b71b9fce6
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be
file_saver: 6cdbcddd690cb02b0c1a0c225b37cd805c2bf8b6
file_picker: b159e0c068aef54932bb15dc9fd1571818edaf49
file_saver: 503e386464dbe118f630e17b4c2e1190fa0cf808
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
flutter_email_sender: aa1e9772696691d02cd91fea829856c11efb8e58
flutter_inappwebview_ios: b89ba3482b96fb25e00c967aae065701b66e9b99
flutter_local_authentication: 989278c681612f1ee0e36019e149137f114b9d7f
flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13
fluttertoast: 2c67e14dce98bbdb200df9e1acf610d7a6264ea1
listen_sharing_intent: fe0b9a59913cc124dd6cbd55cd9f881de5f75759
local_auth_darwin: 553ce4f9b16d3fdfeafce9cf042e7c9f77c1c391
objective_c: 89e720c30d716b036faf9c9684022048eee1eee2
open_file_ios: 5ff7526df64e4394b4fe207636b67a95e83078bb
flutter_email_sender: e03bdda7637bcd3539bfe718fddd980e9508efaa
flutter_inappwebview_ios: 6f63631e2c62a7c350263b13fa5427aedefe81d4
flutter_local_authentication: 1172a4dd88f6306dadce067454e2c4caf07977bb
flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12
fluttertoast: 21eecd6935e7064cc1fcb733a4c5a428f3f24f0f
listen_sharing_intent: 74a842adcbcf7bedf7bbc938c749da9155141b9a
local_auth_darwin: 66e40372f1c29f383a314c738c7446e2f7fdadc3
objective_c: 77e887b5ba1827970907e10e832eec1683f3431d
open_file_ios: 461db5853723763573e140de3193656f91990d9e
OrderedSet: e539b66b644ff081c73a262d24ad552a69be3a94
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564
privacy_screen: 3159a541f5d3a31bea916cfd4e58f9dc722b3fd4
package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
privacy_screen: 1a131c052ceb3c3659934b003b0d397c2381a24e
SDWebImage: f29024626962457f3470184232766516dee8dfea
Sentry: da60d980b197a46db0b35ea12cb8f39af48d8854
sentry_flutter: 27892878729f42701297c628eb90e7c6529f3684
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7
sodium_libs: 6c6d0e83f4ee427c6464caa1f1bdc2abf3ca0b7f
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
sentry_flutter: 2df8b0aab7e4aba81261c230cbea31c82a62dd1b
share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
sodium_libs: 1faae17af662384acbd13e41867a0008cd2e2318
sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
ua_client_hints: 92fe0d139619b73ec9fcb46cc7e079a26178f586
url_launcher_ios: 694010445543906933d732453a59da0a173ae33d
ua_client_hints: aeabd123262c087f0ce151ef96fa3ab77bfc8b38
url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe
PODFILE CHECKSUM: d2d3220ea22664a259778d9e314054751db31361

View File

@@ -18,9 +18,6 @@ final tempDirCleanUpInterval = kDebugMode
? const Duration(hours: 1).inMicroseconds
: const Duration(hours: 6).inMicroseconds;
// Note: 0 indicates no device limit
const publicLinkDeviceLimits = [0, 50, 25, 10, 5, 2, 1];
const uploadTempFilePrefix = "upload_file_";
const blackThumbnailBase64 = '/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAEBAQEBAQEB'

View File

@@ -17,8 +17,6 @@ class WiFiUnavailableError extends Error {}
class SilentlyCancelUploadsError extends Error {}
class SharingNotPermittedForFreeAccountsError extends Error {}
class InvalidFileError extends ArgumentError {
final InvalidReason reason;

View File

@@ -1,12 +0,0 @@
import "package:ente_sharing/models/user.dart";
extension UserExtension on User {
//Some initial users have name in name field.
String? get displayName =>
// ignore: deprecated_member_use_from_same_package, deprecated_member_use
((name?.isEmpty ?? true) ? null : name);
String get nameOrEmail {
return email.substring(0, email.indexOf("@"));
}
}

View File

@@ -349,162 +349,5 @@
"mastodon": "Mastodon",
"matrix": "Matrix",
"discord": "Discord",
"reddit": "Reddit",
"allowDownloads": "Allow downloads",
"sharedByYou": "Shared by you",
"sharedWithYou": "Shared with you",
"manageLink": "Manage link",
"linkExpiry": "Link expiry",
"linkNeverExpires": "Never",
"linkExpired": "Expired",
"linkEnabled": "Enabled",
"setAPassword": "Set a password",
"lockButtonLabel": "Lock",
"enterPassword": "Enter password",
"removeLink": "Remove link",
"sendLink": "Send link",
"setPasswordTitle": "Set password",
"resetPasswordTitle": "Reset password",
"allowAddingFiles": "Allow adding files",
"disableDownloadWarningTitle": "Please note",
"disableDownloadWarningBody": "Viewers can still take screenshots or save a copy of your files using external tools.",
"allowAddFilesDescription": "Allow people with the link to also add files to the shared collection.",
"after1Hour": "After 1 hour",
"after1Day": "After 1 day",
"after1Week": "After 1 week",
"after1Month": "After 1 month",
"after1Year": "After 1 year",
"never": "Never",
"custom": "Custom",
"selectTime": "Select time",
"selectDate": "Select date",
"previous": "Previous",
"done": "Done",
"next": "Next",
"noDeviceLimit": "None",
"linkDeviceLimit": "Device limit",
"expiredLinkInfo": "This link has expired. Please select a new expiry time or disable link expiry.",
"linkExpiresOn": "Link will expire on {expiryTime}",
"shareWithPeopleSectionTitle": "{numberOfPeople, plural, =0 {Share with specific people} =1 {Shared with 1 person} other {Shared with {numberOfPeople} people}}",
"@shareWithPeopleSectionTitle": {
"placeholders": {
"numberOfPeople": {
"type": "int",
"example": "2"
}
}
},
"linkHasExpired": "Link has expired",
"publicLinkEnabled": "Public link enabled",
"shareALink": "Share a link",
"addViewer": "Add viewer",
"addCollaborator": "Add collaborator",
"addANewEmail": "Add a new email",
"orPickAnExistingOne": "Or pick an existing one",
"sharedCollectionSectionDescription": "Create shared and collaborative collections with other Ente users, including users on free plans.",
"createPublicLink": "Create public link",
"addParticipants": "Add participants",
"add": "Add",
"collaboratorsCanAddFilesToTheSharedCollection": "Collaborators can add files to the shared collection.",
"enterEmail": "Enter email",
"viewersSuccessfullyAdded": "{count, plural, =0 {Added 0 viewers} =1 {Added 1 viewer} other {Added {count} viewers}}",
"@viewersSuccessfullyAdded": {
"placeholders": {
"count": {
"type": "int",
"example": "2"
}
},
"description": "Number of viewers that were successfully added to a collection."
},
"collaboratorsSuccessfullyAdded": "{count, plural, =0 {Added 0 collaborator} =1 {Added 1 collaborator} other {Added {count} collaborators}}",
"@collaboratorsSuccessfullyAdded": {
"placeholders": {
"count": {
"type": "int",
"example": "2"
}
},
"description": "Number of collaborators that were successfully added to a collection."
},
"addViewers": "{count, plural, =0 {Add viewer} =1 {Add viewer} other {Add viewers}}",
"addCollaborators": "{count, plural, =0 {Add collaborator} =1 {Add collaborator} other {Add collaborators}}",
"longPressAnEmailToVerifyEndToEndEncryption": "Long press an email to verify end to end encryption.",
"sharing": "Sharing...",
"invalidEmailAddress": "Invalid email address",
"enterValidEmail": "Please enter a valid email address.",
"oops": "Oops",
"youCannotShareWithYourself": "You cannot share with yourself",
"inviteToEnte": "Invite to Ente",
"sendInvite": "Send invite",
"shareTextRecommendUsingEnte": "Download Ente so we can easily share original quality files\n\nhttps://ente.io",
"thisIsYourVerificationId": "This is your Verification ID",
"someoneSharingAlbumsWithYouShouldSeeTheSameId": "Someone sharing albums with you should see the same ID on their device.",
"howToViewShareeVerificationID": "Please ask them to long-press their email address on the settings screen, and verify that the IDs on both devices match.",
"thisIsPersonVerificationId": "This is {email}'s Verification ID",
"@thisIsPersonVerificationId": {
"placeholders": {
"email": {
"type": "String",
"example": "someone@ente.io"
}
}
},
"verificationId": "Verification ID",
"verifyEmailID": "Verify {email}",
"emailNoEnteAccount": "{email} does not have an Ente account.\n\nSend them an invite to share files.",
"shareMyVerificationID": "Here's my verification ID: {verificationID} for ente.io.",
"shareTextConfirmOthersVerificationID": "Hey, can you confirm that this is your ente.io verification ID: {verificationID}",
"passwordLock": "Password lock",
"manage": "Manage",
"addedAs": "Added as",
"removeParticipant": "Remove participant",
"yesConvertToViewer": "Yes, convert to viewer",
"changePermissions": "Change permissions",
"cannotAddMoreFilesAfterBecomingViewer": "{name} will no longer be able to add files to the collection after becoming a viewer.",
"@cannotAddMoreFilesAfterBecomingViewer": {
"description": "Warning message when changing a collaborator to viewer",
"placeholders": {
"name": {
"type": "String",
"example": "John"
}
}
},
"removeWithQuestionMark": "Remove?",
"removeParticipantBody": "{userEmail} will be removed from this shared collection\n\nAny files added by them will also be removed from the collection",
"yesRemove": "Yes, remove",
"remove": "Remove",
"viewer": "Viewer",
"collaborator": "Collaborator",
"collaboratorsCanAddFilesToTheSharedAlbum": "Collaborators can add files to the shared collection.",
"albumParticipantsCount": "{count, plural, =0 {No Participants} =1 {1 Participant} other {{count} Participants}}",
"@albumParticipantsCount": {
"description": "The count of participants in an album",
"placeholders": {
"count": {
"type": "int",
"example": "5"
}
}
},
"addMore": "Add more",
"you": "You",
"albumOwner": "Owner",
"typeOfCollectionTypeIsNotSupportedForRename": "Type of collection {collectionType} is not supported for rename",
"@typeOfCollectionTypeIsNotSupportedForRename": {
"placeholders": {
"collectionType": {
"type": "String",
"example": "no network"
}
}
},
"leaveCollection": "Leave collection",
"filesAddedByYouWillBeRemovedFromTheCollection": "Files added by you will be removed from the collection",
"leaveSharedCollection": "Leave shared collection?",
"noSystemLockFound": "No system lock found",
"toEnableAppLockPleaseSetupDevicePasscodeOrScreen": "To enable app lock, please setup device passcode or screen lock in your system settings.",
"legacy": "Legacy",
"authToManageLegacy": "Please authenticate to manage your trusted contacts"
"reddit": "Reddit"
}

View File

@@ -1017,588 +1017,6 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Reddit'**
String get reddit;
/// No description provided for @allowDownloads.
///
/// In en, this message translates to:
/// **'Allow downloads'**
String get allowDownloads;
/// No description provided for @sharedByYou.
///
/// In en, this message translates to:
/// **'Shared by you'**
String get sharedByYou;
/// No description provided for @sharedWithYou.
///
/// In en, this message translates to:
/// **'Shared with you'**
String get sharedWithYou;
/// No description provided for @manageLink.
///
/// In en, this message translates to:
/// **'Manage link'**
String get manageLink;
/// No description provided for @linkExpiry.
///
/// In en, this message translates to:
/// **'Link expiry'**
String get linkExpiry;
/// No description provided for @linkNeverExpires.
///
/// In en, this message translates to:
/// **'Never'**
String get linkNeverExpires;
/// No description provided for @linkExpired.
///
/// In en, this message translates to:
/// **'Expired'**
String get linkExpired;
/// No description provided for @linkEnabled.
///
/// In en, this message translates to:
/// **'Enabled'**
String get linkEnabled;
/// No description provided for @setAPassword.
///
/// In en, this message translates to:
/// **'Set a password'**
String get setAPassword;
/// No description provided for @lockButtonLabel.
///
/// In en, this message translates to:
/// **'Lock'**
String get lockButtonLabel;
/// No description provided for @enterPassword.
///
/// In en, this message translates to:
/// **'Enter password'**
String get enterPassword;
/// No description provided for @removeLink.
///
/// In en, this message translates to:
/// **'Remove link'**
String get removeLink;
/// No description provided for @sendLink.
///
/// In en, this message translates to:
/// **'Send link'**
String get sendLink;
/// No description provided for @setPasswordTitle.
///
/// In en, this message translates to:
/// **'Set password'**
String get setPasswordTitle;
/// No description provided for @resetPasswordTitle.
///
/// In en, this message translates to:
/// **'Reset password'**
String get resetPasswordTitle;
/// No description provided for @allowAddingFiles.
///
/// In en, this message translates to:
/// **'Allow adding files'**
String get allowAddingFiles;
/// No description provided for @disableDownloadWarningTitle.
///
/// In en, this message translates to:
/// **'Please note'**
String get disableDownloadWarningTitle;
/// No description provided for @disableDownloadWarningBody.
///
/// In en, this message translates to:
/// **'Viewers can still take screenshots or save a copy of your files using external tools.'**
String get disableDownloadWarningBody;
/// No description provided for @allowAddFilesDescription.
///
/// In en, this message translates to:
/// **'Allow people with the link to also add files to the shared collection.'**
String get allowAddFilesDescription;
/// No description provided for @after1Hour.
///
/// In en, this message translates to:
/// **'After 1 hour'**
String get after1Hour;
/// No description provided for @after1Day.
///
/// In en, this message translates to:
/// **'After 1 day'**
String get after1Day;
/// No description provided for @after1Week.
///
/// In en, this message translates to:
/// **'After 1 week'**
String get after1Week;
/// No description provided for @after1Month.
///
/// In en, this message translates to:
/// **'After 1 month'**
String get after1Month;
/// No description provided for @after1Year.
///
/// In en, this message translates to:
/// **'After 1 year'**
String get after1Year;
/// No description provided for @never.
///
/// In en, this message translates to:
/// **'Never'**
String get never;
/// No description provided for @custom.
///
/// In en, this message translates to:
/// **'Custom'**
String get custom;
/// No description provided for @selectTime.
///
/// In en, this message translates to:
/// **'Select time'**
String get selectTime;
/// No description provided for @selectDate.
///
/// In en, this message translates to:
/// **'Select date'**
String get selectDate;
/// No description provided for @previous.
///
/// In en, this message translates to:
/// **'Previous'**
String get previous;
/// No description provided for @done.
///
/// In en, this message translates to:
/// **'Done'**
String get done;
/// No description provided for @next.
///
/// In en, this message translates to:
/// **'Next'**
String get next;
/// No description provided for @noDeviceLimit.
///
/// In en, this message translates to:
/// **'None'**
String get noDeviceLimit;
/// No description provided for @linkDeviceLimit.
///
/// In en, this message translates to:
/// **'Device limit'**
String get linkDeviceLimit;
/// No description provided for @expiredLinkInfo.
///
/// In en, this message translates to:
/// **'This link has expired. Please select a new expiry time or disable link expiry.'**
String get expiredLinkInfo;
/// No description provided for @linkExpiresOn.
///
/// In en, this message translates to:
/// **'Link will expire on {expiryTime}'**
String linkExpiresOn(Object expiryTime);
/// No description provided for @shareWithPeopleSectionTitle.
///
/// In en, this message translates to:
/// **'{numberOfPeople, plural, =0 {Share with specific people} =1 {Shared with 1 person} other {Shared with {numberOfPeople} people}}'**
String shareWithPeopleSectionTitle(int numberOfPeople);
/// No description provided for @linkHasExpired.
///
/// In en, this message translates to:
/// **'Link has expired'**
String get linkHasExpired;
/// No description provided for @publicLinkEnabled.
///
/// In en, this message translates to:
/// **'Public link enabled'**
String get publicLinkEnabled;
/// No description provided for @shareALink.
///
/// In en, this message translates to:
/// **'Share a link'**
String get shareALink;
/// No description provided for @addViewer.
///
/// In en, this message translates to:
/// **'Add viewer'**
String get addViewer;
/// No description provided for @addCollaborator.
///
/// In en, this message translates to:
/// **'Add collaborator'**
String get addCollaborator;
/// No description provided for @addANewEmail.
///
/// In en, this message translates to:
/// **'Add a new email'**
String get addANewEmail;
/// No description provided for @orPickAnExistingOne.
///
/// In en, this message translates to:
/// **'Or pick an existing one'**
String get orPickAnExistingOne;
/// No description provided for @sharedCollectionSectionDescription.
///
/// In en, this message translates to:
/// **'Create shared and collaborative collections with other Ente users, including users on free plans.'**
String get sharedCollectionSectionDescription;
/// No description provided for @createPublicLink.
///
/// In en, this message translates to:
/// **'Create public link'**
String get createPublicLink;
/// No description provided for @addParticipants.
///
/// In en, this message translates to:
/// **'Add participants'**
String get addParticipants;
/// No description provided for @add.
///
/// In en, this message translates to:
/// **'Add'**
String get add;
/// No description provided for @collaboratorsCanAddFilesToTheSharedCollection.
///
/// In en, this message translates to:
/// **'Collaborators can add files to the shared collection.'**
String get collaboratorsCanAddFilesToTheSharedCollection;
/// No description provided for @enterEmail.
///
/// In en, this message translates to:
/// **'Enter email'**
String get enterEmail;
/// Number of viewers that were successfully added to a collection.
///
/// In en, this message translates to:
/// **'{count, plural, =0 {Added 0 viewers} =1 {Added 1 viewer} other {Added {count} viewers}}'**
String viewersSuccessfullyAdded(int count);
/// Number of collaborators that were successfully added to a collection.
///
/// In en, this message translates to:
/// **'{count, plural, =0 {Added 0 collaborator} =1 {Added 1 collaborator} other {Added {count} collaborators}}'**
String collaboratorsSuccessfullyAdded(int count);
/// No description provided for @addViewers.
///
/// In en, this message translates to:
/// **'{count, plural, =0 {Add viewer} =1 {Add viewer} other {Add viewers}}'**
String addViewers(num count);
/// No description provided for @addCollaborators.
///
/// In en, this message translates to:
/// **'{count, plural, =0 {Add collaborator} =1 {Add collaborator} other {Add collaborators}}'**
String addCollaborators(num count);
/// No description provided for @longPressAnEmailToVerifyEndToEndEncryption.
///
/// In en, this message translates to:
/// **'Long press an email to verify end to end encryption.'**
String get longPressAnEmailToVerifyEndToEndEncryption;
/// No description provided for @sharing.
///
/// In en, this message translates to:
/// **'Sharing...'**
String get sharing;
/// No description provided for @invalidEmailAddress.
///
/// In en, this message translates to:
/// **'Invalid email address'**
String get invalidEmailAddress;
/// No description provided for @enterValidEmail.
///
/// In en, this message translates to:
/// **'Please enter a valid email address.'**
String get enterValidEmail;
/// No description provided for @oops.
///
/// In en, this message translates to:
/// **'Oops'**
String get oops;
/// No description provided for @youCannotShareWithYourself.
///
/// In en, this message translates to:
/// **'You cannot share with yourself'**
String get youCannotShareWithYourself;
/// No description provided for @inviteToEnte.
///
/// In en, this message translates to:
/// **'Invite to Ente'**
String get inviteToEnte;
/// No description provided for @sendInvite.
///
/// In en, this message translates to:
/// **'Send invite'**
String get sendInvite;
/// No description provided for @shareTextRecommendUsingEnte.
///
/// In en, this message translates to:
/// **'Download Ente so we can easily share original quality files\n\nhttps://ente.io'**
String get shareTextRecommendUsingEnte;
/// No description provided for @thisIsYourVerificationId.
///
/// In en, this message translates to:
/// **'This is your Verification ID'**
String get thisIsYourVerificationId;
/// No description provided for @someoneSharingAlbumsWithYouShouldSeeTheSameId.
///
/// In en, this message translates to:
/// **'Someone sharing albums with you should see the same ID on their device.'**
String get someoneSharingAlbumsWithYouShouldSeeTheSameId;
/// No description provided for @howToViewShareeVerificationID.
///
/// In en, this message translates to:
/// **'Please ask them to long-press their email address on the settings screen, and verify that the IDs on both devices match.'**
String get howToViewShareeVerificationID;
/// No description provided for @thisIsPersonVerificationId.
///
/// In en, this message translates to:
/// **'This is {email}\'s Verification ID'**
String thisIsPersonVerificationId(String email);
/// No description provided for @verificationId.
///
/// In en, this message translates to:
/// **'Verification ID'**
String get verificationId;
/// No description provided for @verifyEmailID.
///
/// In en, this message translates to:
/// **'Verify {email}'**
String verifyEmailID(Object email);
/// No description provided for @emailNoEnteAccount.
///
/// In en, this message translates to:
/// **'{email} does not have an Ente account.\n\nSend them an invite to share files.'**
String emailNoEnteAccount(Object email);
/// No description provided for @shareMyVerificationID.
///
/// In en, this message translates to:
/// **'Here\'s my verification ID: {verificationID} for ente.io.'**
String shareMyVerificationID(Object verificationID);
/// No description provided for @shareTextConfirmOthersVerificationID.
///
/// In en, this message translates to:
/// **'Hey, can you confirm that this is your ente.io verification ID: {verificationID}'**
String shareTextConfirmOthersVerificationID(Object verificationID);
/// No description provided for @passwordLock.
///
/// In en, this message translates to:
/// **'Password lock'**
String get passwordLock;
/// No description provided for @manage.
///
/// In en, this message translates to:
/// **'Manage'**
String get manage;
/// No description provided for @addedAs.
///
/// In en, this message translates to:
/// **'Added as'**
String get addedAs;
/// No description provided for @removeParticipant.
///
/// In en, this message translates to:
/// **'Remove participant'**
String get removeParticipant;
/// No description provided for @yesConvertToViewer.
///
/// In en, this message translates to:
/// **'Yes, convert to viewer'**
String get yesConvertToViewer;
/// No description provided for @changePermissions.
///
/// In en, this message translates to:
/// **'Change permissions'**
String get changePermissions;
/// Warning message when changing a collaborator to viewer
///
/// In en, this message translates to:
/// **'{name} will no longer be able to add files to the collection after becoming a viewer.'**
String cannotAddMoreFilesAfterBecomingViewer(String name);
/// No description provided for @removeWithQuestionMark.
///
/// In en, this message translates to:
/// **'Remove?'**
String get removeWithQuestionMark;
/// No description provided for @removeParticipantBody.
///
/// In en, this message translates to:
/// **'{userEmail} will be removed from this shared collection\n\nAny files added by them will also be removed from the collection'**
String removeParticipantBody(Object userEmail);
/// No description provided for @yesRemove.
///
/// In en, this message translates to:
/// **'Yes, remove'**
String get yesRemove;
/// No description provided for @remove.
///
/// In en, this message translates to:
/// **'Remove'**
String get remove;
/// No description provided for @viewer.
///
/// In en, this message translates to:
/// **'Viewer'**
String get viewer;
/// No description provided for @collaborator.
///
/// In en, this message translates to:
/// **'Collaborator'**
String get collaborator;
/// No description provided for @collaboratorsCanAddFilesToTheSharedAlbum.
///
/// In en, this message translates to:
/// **'Collaborators can add files to the shared collection.'**
String get collaboratorsCanAddFilesToTheSharedAlbum;
/// The count of participants in an album
///
/// In en, this message translates to:
/// **'{count, plural, =0 {No Participants} =1 {1 Participant} other {{count} Participants}}'**
String albumParticipantsCount(int count);
/// No description provided for @addMore.
///
/// In en, this message translates to:
/// **'Add more'**
String get addMore;
/// No description provided for @you.
///
/// In en, this message translates to:
/// **'You'**
String get you;
/// No description provided for @albumOwner.
///
/// In en, this message translates to:
/// **'Owner'**
String get albumOwner;
/// No description provided for @typeOfCollectionTypeIsNotSupportedForRename.
///
/// In en, this message translates to:
/// **'Type of collection {collectionType} is not supported for rename'**
String typeOfCollectionTypeIsNotSupportedForRename(String collectionType);
/// No description provided for @leaveCollection.
///
/// In en, this message translates to:
/// **'Leave collection'**
String get leaveCollection;
/// No description provided for @filesAddedByYouWillBeRemovedFromTheCollection.
///
/// In en, this message translates to:
/// **'Files added by you will be removed from the collection'**
String get filesAddedByYouWillBeRemovedFromTheCollection;
/// No description provided for @leaveSharedCollection.
///
/// In en, this message translates to:
/// **'Leave shared collection?'**
String get leaveSharedCollection;
/// No description provided for @noSystemLockFound.
///
/// In en, this message translates to:
/// **'No system lock found'**
String get noSystemLockFound;
/// No description provided for @toEnableAppLockPleaseSetupDevicePasscodeOrScreen.
///
/// In en, this message translates to:
/// **'To enable app lock, please setup device passcode or screen lock in your system settings.'**
String get toEnableAppLockPleaseSetupDevicePasscodeOrScreen;
/// No description provided for @legacy.
///
/// In en, this message translates to:
/// **'Legacy'**
String get legacy;
/// No description provided for @authToManageLegacy.
///
/// In en, this message translates to:
/// **'Please authenticate to manage your trusted contacts'**
String get authToManageLegacy;
}
class _AppLocalizationsDelegate

View File

@@ -534,380 +534,4 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get reddit => 'Reddit';
@override
String get allowDownloads => 'Allow downloads';
@override
String get sharedByYou => 'Shared by you';
@override
String get sharedWithYou => 'Shared with you';
@override
String get manageLink => 'Manage link';
@override
String get linkExpiry => 'Link expiry';
@override
String get linkNeverExpires => 'Never';
@override
String get linkExpired => 'Expired';
@override
String get linkEnabled => 'Enabled';
@override
String get setAPassword => 'Set a password';
@override
String get lockButtonLabel => 'Lock';
@override
String get enterPassword => 'Enter password';
@override
String get removeLink => 'Remove link';
@override
String get sendLink => 'Send link';
@override
String get setPasswordTitle => 'Set password';
@override
String get resetPasswordTitle => 'Reset password';
@override
String get allowAddingFiles => 'Allow adding files';
@override
String get disableDownloadWarningTitle => 'Please note';
@override
String get disableDownloadWarningBody =>
'Viewers can still take screenshots or save a copy of your files using external tools.';
@override
String get allowAddFilesDescription =>
'Allow people with the link to also add files to the shared collection.';
@override
String get after1Hour => 'After 1 hour';
@override
String get after1Day => 'After 1 day';
@override
String get after1Week => 'After 1 week';
@override
String get after1Month => 'After 1 month';
@override
String get after1Year => 'After 1 year';
@override
String get never => 'Never';
@override
String get custom => 'Custom';
@override
String get selectTime => 'Select time';
@override
String get selectDate => 'Select date';
@override
String get previous => 'Previous';
@override
String get done => 'Done';
@override
String get next => 'Next';
@override
String get noDeviceLimit => 'None';
@override
String get linkDeviceLimit => 'Device limit';
@override
String get expiredLinkInfo =>
'This link has expired. Please select a new expiry time or disable link expiry.';
@override
String linkExpiresOn(Object expiryTime) {
return 'Link will expire on $expiryTime';
}
@override
String shareWithPeopleSectionTitle(int numberOfPeople) {
String _temp0 = intl.Intl.pluralLogic(
numberOfPeople,
locale: localeName,
other: 'Shared with $numberOfPeople people',
one: 'Shared with 1 person',
zero: 'Share with specific people',
);
return '$_temp0';
}
@override
String get linkHasExpired => 'Link has expired';
@override
String get publicLinkEnabled => 'Public link enabled';
@override
String get shareALink => 'Share a link';
@override
String get addViewer => 'Add viewer';
@override
String get addCollaborator => 'Add collaborator';
@override
String get addANewEmail => 'Add a new email';
@override
String get orPickAnExistingOne => 'Or pick an existing one';
@override
String get sharedCollectionSectionDescription =>
'Create shared and collaborative collections with other Ente users, including users on free plans.';
@override
String get createPublicLink => 'Create public link';
@override
String get addParticipants => 'Add participants';
@override
String get add => 'Add';
@override
String get collaboratorsCanAddFilesToTheSharedCollection =>
'Collaborators can add files to the shared collection.';
@override
String get enterEmail => 'Enter email';
@override
String viewersSuccessfullyAdded(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'Added $count viewers',
one: 'Added 1 viewer',
zero: 'Added 0 viewers',
);
return '$_temp0';
}
@override
String collaboratorsSuccessfullyAdded(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'Added $count collaborators',
one: 'Added 1 collaborator',
zero: 'Added 0 collaborator',
);
return '$_temp0';
}
@override
String addViewers(num count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'Add viewers',
one: 'Add viewer',
zero: 'Add viewer',
);
return '$_temp0';
}
@override
String addCollaborators(num count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'Add collaborators',
one: 'Add collaborator',
zero: 'Add collaborator',
);
return '$_temp0';
}
@override
String get longPressAnEmailToVerifyEndToEndEncryption =>
'Long press an email to verify end to end encryption.';
@override
String get sharing => 'Sharing...';
@override
String get invalidEmailAddress => 'Invalid email address';
@override
String get enterValidEmail => 'Please enter a valid email address.';
@override
String get oops => 'Oops';
@override
String get youCannotShareWithYourself => 'You cannot share with yourself';
@override
String get inviteToEnte => 'Invite to Ente';
@override
String get sendInvite => 'Send invite';
@override
String get shareTextRecommendUsingEnte =>
'Download Ente so we can easily share original quality files\n\nhttps://ente.io';
@override
String get thisIsYourVerificationId => 'This is your Verification ID';
@override
String get someoneSharingAlbumsWithYouShouldSeeTheSameId =>
'Someone sharing albums with you should see the same ID on their device.';
@override
String get howToViewShareeVerificationID =>
'Please ask them to long-press their email address on the settings screen, and verify that the IDs on both devices match.';
@override
String thisIsPersonVerificationId(String email) {
return 'This is $email\'s Verification ID';
}
@override
String get verificationId => 'Verification ID';
@override
String verifyEmailID(Object email) {
return 'Verify $email';
}
@override
String emailNoEnteAccount(Object email) {
return '$email does not have an Ente account.\n\nSend them an invite to share files.';
}
@override
String shareMyVerificationID(Object verificationID) {
return 'Here\'s my verification ID: $verificationID for ente.io.';
}
@override
String shareTextConfirmOthersVerificationID(Object verificationID) {
return 'Hey, can you confirm that this is your ente.io verification ID: $verificationID';
}
@override
String get passwordLock => 'Password lock';
@override
String get manage => 'Manage';
@override
String get addedAs => 'Added as';
@override
String get removeParticipant => 'Remove participant';
@override
String get yesConvertToViewer => 'Yes, convert to viewer';
@override
String get changePermissions => 'Change permissions';
@override
String cannotAddMoreFilesAfterBecomingViewer(String name) {
return '$name will no longer be able to add files to the collection after becoming a viewer.';
}
@override
String get removeWithQuestionMark => 'Remove?';
@override
String removeParticipantBody(Object userEmail) {
return '$userEmail will be removed from this shared collection\n\nAny files added by them will also be removed from the collection';
}
@override
String get yesRemove => 'Yes, remove';
@override
String get remove => 'Remove';
@override
String get viewer => 'Viewer';
@override
String get collaborator => 'Collaborator';
@override
String get collaboratorsCanAddFilesToTheSharedAlbum =>
'Collaborators can add files to the shared collection.';
@override
String albumParticipantsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count Participants',
one: '1 Participant',
zero: 'No Participants',
);
return '$_temp0';
}
@override
String get addMore => 'Add more';
@override
String get you => 'You';
@override
String get albumOwner => 'Owner';
@override
String typeOfCollectionTypeIsNotSupportedForRename(String collectionType) {
return 'Type of collection $collectionType is not supported for rename';
}
@override
String get leaveCollection => 'Leave collection';
@override
String get filesAddedByYouWillBeRemovedFromTheCollection =>
'Files added by you will be removed from the collection';
@override
String get leaveSharedCollection => 'Leave shared collection?';
@override
String get noSystemLockFound => 'No system lock found';
@override
String get toEnableAppLockPleaseSetupDevicePasscodeOrScreen =>
'To enable app lock, please setup device passcode or screen lock in your system settings.';
@override
String get legacy => 'Legacy';
@override
String get authToManageLegacy =>
'Please authenticate to manage your trusted contacts';
}

View File

@@ -4,7 +4,6 @@ import 'dart:io';
import 'package:adaptive_theme/adaptive_theme.dart';
import 'package:ente_accounts/services/user_service.dart';
import 'package:ente_crypto_dart/ente_crypto_dart.dart';
import "package:ente_legacy/services/emergency_service.dart";
import 'package:ente_lock_screen/lock_screen_settings.dart';
import 'package:ente_lock_screen/ui/app_lock.dart';
import 'package:ente_lock_screen/ui/lock_screen.dart';
@@ -170,8 +169,4 @@ Future<void> _init(bool bool, {String? via}) async {
packageInfo,
);
await TrashService.instance.init(preferences);
await EmergencyContactService.instance.init(
UserService.instance,
Configuration.instance,
);
}

View File

@@ -1,23 +1,16 @@
import "dart:async";
import 'dart:convert';
import 'dart:math';
import 'dart:typed_data';
import 'package:dio/dio.dart';
import 'package:ente_crypto_dart/ente_crypto_dart.dart';
import "package:ente_events/event_bus.dart";
import 'package:ente_network/network.dart';
import "package:ente_sharing/collection_sharing_service.dart";
import "package:ente_sharing/models/user.dart";
import 'package:locker/core/errors.dart';
import "package:locker/events/collections_updated_event.dart";
import "package:locker/services/collections/collections_db.dart";
import "package:locker/services/collections/collections_service.dart";
import 'package:locker/services/collections/models/collection.dart';
import 'package:locker/services/collections/models/collection_file_item.dart';
import 'package:locker/services/collections/models/collection_magic.dart';
import 'package:locker/services/collections/models/diff.dart';
import "package:locker/services/collections/models/public_url.dart";
import 'package:locker/services/configuration.dart';
import "package:locker/services/files/sync/metadata_updater_service.dart";
import 'package:locker/services/files/sync/models/file.dart';
@@ -36,11 +29,7 @@ class CollectionApiClient {
final _enteDio = Network.instance.enteDio;
final _config = Configuration.instance;
late CollectionDB _db;
Future<void> init() async {
_db = CollectionDB.instance;
}
Future<void> init() async {}
Future<List<Collection>> getCollections(int sinceTime) async {
try {
@@ -172,18 +161,6 @@ class CollectionApiClient {
}
}
Future<void> leaveCollection(Collection collection) async {
await CollectionSharingService.instance.leaveCollection(collection.id);
await _handleCollectionDeletion(collection);
}
Future<void> _handleCollectionDeletion(Collection collection) async {
await _db.deleteCollection(collection);
final deletedCollection = collection.copyWith(isDeleted: true);
await _updateCollectionInDB(deletedCollection);
await CollectionService.instance.sync();
}
Future<void> move(
EnteFile file,
Collection fromCollection,
@@ -417,86 +394,6 @@ class CollectionApiClient {
return collection;
});
}
Future<void> createShareUrl(
Collection collection, {
bool enableCollect = false,
}) async {
final response = await CollectionSharingService.instance.createShareUrl(
collection.id,
enableCollect,
);
collection.publicURLs.add(PublicURL.fromMap(response.data["result"]));
await _updateCollectionInDB(collection);
Bus.instance.fire(CollectionsUpdatedEvent());
}
Future<void> disableShareUrl(Collection collection) async {
await CollectionSharingService.instance.disableShareUrl(collection.id);
collection.publicURLs.clear();
await _updateCollectionInDB(collection);
Bus.instance.fire(CollectionsUpdatedEvent());
}
Future<void> updateShareUrl(
Collection collection,
Map<String, dynamic> prop,
) async {
prop.putIfAbsent('collectionID', () => collection.id);
final response = await CollectionSharingService.instance.updateShareUrl(
collection.id,
prop,
);
// remove existing url information
collection.publicURLs.clear();
collection.publicURLs.add(PublicURL.fromMap(response.data["result"]));
await _updateCollectionInDB(collection);
Bus.instance.fire(CollectionsUpdatedEvent());
}
Future<List<User>> share(
int collectionID,
String email,
String publicKey,
CollectionParticipantRole role,
) async {
final collectionKey =
CollectionService.instance.getCollectionKey(collectionID);
final encryptedKey = CryptoUtil.sealSync(
collectionKey,
CryptoUtil.base642bin(publicKey),
);
final sharees = await CollectionSharingService.instance.share(
collectionID,
email,
publicKey,
role.toStringVal(),
collectionKey,
encryptedKey,
);
final collection = CollectionService.instance.getFromCache(collectionID);
final updatedCollection = collection!.copyWith(sharees: sharees);
await _updateCollectionInDB(updatedCollection);
return sharees;
}
Future<List<User>> unshare(int collectionID, String email) async {
final sharees =
await CollectionSharingService.instance.unshare(collectionID, email);
final collection = CollectionService.instance.getFromCache(collectionID);
final updatedCollection = collection!.copyWith(sharees: sharees);
await _updateCollectionInDB(updatedCollection);
return sharees;
}
Future<void> _updateCollectionInDB(Collection collection) async {
await _db.updateCollections([collection]);
CollectionService.instance.updateCollectionCache(collection);
}
}
class CreateRequest {

View File

@@ -1,9 +1,9 @@
import 'dart:convert';
import "package:ente_base/models/database.dart";
import "package:ente_sharing/models/user.dart";
import 'package:locker/services/collections/models/collection.dart';
import 'package:locker/services/collections/models/public_url.dart';
import 'package:locker/services/collections/models/user.dart';
import 'package:locker/services/files/sync/models/file.dart';
import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';

View File

@@ -4,15 +4,10 @@ import 'dart:typed_data';
import 'package:ente_events/event_bus.dart';
import 'package:ente_events/models/signed_in_event.dart';
import "package:ente_sharing/models/user.dart";
import "package:fast_base58/fast_base58.dart";
import "package:flutter/foundation.dart";
import 'package:locker/events/collections_updated_event.dart';
import "package:locker/services/collections/collections_api_client.dart";
import "package:locker/services/collections/collections_db.dart";
import 'package:locker/services/collections/models/collection.dart';
import "package:locker/services/collections/models/collection_items.dart";
import "package:locker/services/collections/models/public_url.dart";
import 'package:locker/services/configuration.dart';
import 'package:locker/services/files/sync/models/file.dart';
import 'package:locker/services/trash/models/trash_item_request.dart';
@@ -21,6 +16,8 @@ import "package:locker/utils/crypto_helper.dart";
import 'package:logging/logging.dart';
class CollectionService {
CollectionService._privateConstructor();
static final CollectionService instance =
CollectionService._privateConstructor();
@@ -39,16 +36,7 @@ class CollectionService {
};
final _logger = Logger("CollectionService");
late CollectionApiClient _apiClient;
late CollectionDB _db;
final _collectionIDToCollections = <int, Collection>{};
CollectionService._privateConstructor() {
_db = CollectionDB.instance;
_apiClient = CollectionApiClient.instance;
}
final _apiClient = CollectionApiClient.instance;
Future<void> init() async {
if (Configuration.instance.hasConfiguredAccount()) {
@@ -62,45 +50,41 @@ class CollectionService {
}
Future<void> sync() async {
final updatedCollections =
await CollectionApiClient.instance.getCollections(_db.getSyncTime());
final updatedCollections = await CollectionApiClient.instance
.getCollections(CollectionDB.instance.getSyncTime());
if (updatedCollections.isEmpty) {
_logger.info("No collections to sync.");
return;
}
await _db.updateCollections(updatedCollections);
// Update the cache with new/updated collections
for (final collection in updatedCollections) {
_collectionIDToCollections[collection.id] = collection;
}
await _db.setSyncTime(updatedCollections.last.updationTime);
await CollectionDB.instance.updateCollections(updatedCollections);
await CollectionDB.instance
.setSyncTime(updatedCollections.last.updationTime);
final List<Future> fileFutures = [];
for (final collection in updatedCollections) {
if (collection.isDeleted) {
await _db.deleteCollection(collection);
_collectionIDToCollections.remove(collection.id);
await CollectionDB.instance.deleteCollection(collection);
continue;
}
final syncTime = _db.getCollectionSyncTime(collection.id);
final syncTime =
CollectionDB.instance.getCollectionSyncTime(collection.id);
fileFutures.add(
_apiClient.getFiles(collection, syncTime).then((diff) async {
CollectionApiClient.instance
.getFiles(collection, syncTime)
.then((diff) async {
if (diff.updatedFiles.isNotEmpty) {
await _db.addFilesToCollection(
await CollectionDB.instance.addFilesToCollection(
collection,
diff.updatedFiles,
);
}
if (diff.deletedFiles.isNotEmpty) {
await _db.deleteFilesFromCollection(
await CollectionDB.instance.deleteFilesFromCollection(
collection,
diff.deletedFiles,
);
}
await _db.setCollectionSyncTime(
collection.id,
diff.latestUpdatedAtTime,
);
await CollectionDB.instance
.setCollectionSyncTime(collection.id, diff.latestUpdatedAtTime);
}).catchError((e) {
_logger.warning(
"Failed to fetch files for collection ${collection.id}: $e",
@@ -116,7 +100,7 @@ class CollectionService {
bool hasCompletedFirstSync() {
return Configuration.instance.hasConfiguredAccount() &&
_db.getSyncTime() > 0;
CollectionDB.instance.getSyncTime() > 0;
}
Future<Collection> createCollection(
@@ -136,37 +120,17 @@ class CollectionService {
}
Future<List<Collection>> getCollections() async {
return _db.getCollections();
}
Future<SharedCollections> getSharedCollections() async {
final List<Collection> outgoing = [];
final List<Collection> incoming = [];
final List<Collection> quickLinks = [];
final List<Collection> collections = await getCollections();
for (final c in collections) {
if (c.owner.id == Configuration.instance.getUserID()) {
if (c.hasSharees || c.hasLink && !c.isQuickLinkCollection()) {
outgoing.add(c);
} else if (c.isQuickLinkCollection()) {
quickLinks.add(c);
}
} else {
incoming.add(c);
}
}
return SharedCollections(outgoing, incoming, quickLinks);
return CollectionDB.instance.getCollections();
}
Future<List<Collection>> getCollectionsForFile(EnteFile file) async {
return _db.getCollectionsForFile(file);
return CollectionDB.instance.getCollectionsForFile(file);
}
Future<List<EnteFile>> getFilesInCollection(Collection collection) async {
try {
final files = await _db.getFilesInCollection(collection);
final files =
await CollectionDB.instance.getFilesInCollection(collection);
return files;
} catch (e) {
_logger.severe(
@@ -178,7 +142,7 @@ class CollectionService {
Future<List<EnteFile>> getAllFiles() async {
try {
final allFiles = await _db.getAllFiles();
final allFiles = await CollectionDB.instance.getAllFiles();
return allFiles;
} catch (e) {
_logger.severe("Failed to fetch all files: $e");
@@ -214,7 +178,7 @@ class CollectionService {
Future<void> rename(Collection collection, String newName) async {
try {
await _apiClient.rename(
await CollectionApiClient.instance.rename(
collection,
newName,
);
@@ -248,10 +212,6 @@ class CollectionService {
}).catchError((error) {
_logger.severe("Failed to initialize collections: $error");
});
final collections = await _db.getCollections();
for (final collection in collections) {
_collectionIDToCollections[collection.id] = collection;
}
}
Future<Collection> _getOrCreateImportantCollection() async {
@@ -353,17 +313,12 @@ class CollectionService {
}
Future<Collection> getCollection(int collectionID) async {
if (_collectionIDToCollections.containsKey(collectionID)) {
return _collectionIDToCollections[collectionID]!;
}
final collection = await _db.getCollection(collectionID);
_collectionIDToCollections[collectionID] = collection;
return collection;
return await CollectionDB.instance.getCollection(collectionID);
}
Uint8List getCollectionKey(int collectionID) {
final collection = _collectionIDToCollections[collectionID];
final collectionKey = CryptoHelper.instance.getCollectionKey(collection!);
Future<Uint8List> getCollectionKey(int collectionID) async {
final collection = await getCollection(collectionID);
final collectionKey = CryptoHelper.instance.getCollectionKey(collection);
return collectionKey;
}
@@ -385,94 +340,4 @@ class CollectionService {
rethrow;
}
}
// getActiveCollections returns list of collections which are not deleted yet
List<Collection> getActiveCollections() {
return _collectionIDToCollections.values
.toList()
.where((element) => !element.isDeleted)
.toList();
}
/// Returns Contacts(Users) that are relevant to the account owner.
/// Note: "User" refers to the account owner in the points below.
/// This includes:
/// - Collaborators and viewers of collections owned by user
/// - Owners of collections shared to user.
/// - All collaborators of collections in which user is a collaborator or
/// a viewer.
List<User> getRelevantContacts() {
final List<User> relevantUsers = [];
final existingEmails = <String>{};
final int ownerID = Configuration.instance.getUserID()!;
final String ownerEmail = Configuration.instance.getEmail()!;
existingEmails.add(ownerEmail);
for (final c in getActiveCollections()) {
// Add collaborators and viewers of collections owned by user
if (c.owner.id == ownerID) {
for (final User u in c.sharees) {
if (u.id != null && u.email.isNotEmpty) {
if (!existingEmails.contains(u.email)) {
relevantUsers.add(u);
existingEmails.add(u.email);
}
}
}
} else if (c.owner.id != null && c.owner.email.isNotEmpty) {
// Add owners of collections shared with user
if (!existingEmails.contains(c.owner.email)) {
relevantUsers.add(c.owner);
existingEmails.add(c.owner.email);
}
// Add collaborators of collections shared with user where user is a
// viewer or a collaborator
for (final User u in c.sharees) {
if (u.id != null &&
u.email.isNotEmpty &&
u.email == ownerEmail &&
(u.isCollaborator || u.isViewer)) {
for (final User u in c.sharees) {
if (u.id != null && u.email.isNotEmpty && u.isCollaborator) {
if (!existingEmails.contains(u.email)) {
relevantUsers.add(u);
existingEmails.add(u.email);
}
}
}
break;
}
}
}
}
return relevantUsers;
}
String getPublicUrl(Collection c) {
final PublicURL url = c.publicURLs.firstOrNull!;
final Uri publicUrl = Uri.parse(url.url);
final cKey = getCollectionKey(c.id);
final String collectionKey = Base58Encode(cKey);
final String urlValue = "${publicUrl.toString()}#$collectionKey";
return urlValue;
}
void clearCache() {
_collectionIDToCollections.clear();
}
// Methods for managing collection cache
void updateCollectionCache(Collection collection) {
_collectionIDToCollections[collection.id] = collection;
}
void removeFromCache(int collectionId) {
_collectionIDToCollections.remove(collectionId);
}
Collection? getFromCache(int collectionId) {
return _collectionIDToCollections[collectionId];
}
}

View File

@@ -1,9 +1,9 @@
import 'dart:core';
import "package:ente_sharing/models/user.dart";
import 'package:flutter/foundation.dart';
import 'package:locker/services/collections/models/collection_magic.dart';
import 'package:locker/services/collections/models/public_url.dart';
import 'package:locker/services/collections/models/public_url.dart';
import 'package:locker/services/collections/models/user.dart';
import 'package:locker/services/files/sync/models/common_keys.dart';
class Collection {

View File

@@ -1,9 +0,0 @@
import "package:locker/services/collections/models/collection.dart";
class SharedCollections {
final List<Collection> outgoing;
final List<Collection> incoming;
final List<Collection> quickLinks;
SharedCollections(this.outgoing, this.incoming, this.quickLinks);
}

View File

@@ -1,33 +0,0 @@
import "package:flutter/material.dart";
import "package:locker/services/collections/models/collection.dart";
enum CollectionViewType {
ownedCollection,
sharedCollection,
hiddenOwnedCollection,
hiddenSection,
quickLink,
uncategorized,
favorite
}
CollectionViewType getCollectionViewType(Collection c, int userID) {
if (!c.isOwner(userID)) {
return CollectionViewType.sharedCollection;
}
if (c.isDefaultHidden()) {
return CollectionViewType.hiddenSection;
} else if (c.type == CollectionType.uncategorized) {
return CollectionViewType.uncategorized;
} else if (c.type == CollectionType.favorites) {
return CollectionViewType.favorite;
} else if (c.isQuickLinkCollection()) {
return CollectionViewType.quickLink;
} else if (c.isHidden()) {
return CollectionViewType.hiddenOwnedCollection;
}
debugPrint("Unknown collection type for collection ${c.id}, falling back to "
"default");
return CollectionViewType.ownedCollection;
}

View File

@@ -1,115 +0,0 @@
import "dart:math";
import "package:ente_ui/theme/ente_theme.dart";
import "package:flutter/material.dart";
import "package:locker/l10n/l10n.dart";
import "package:locker/services/collections/models/collection.dart";
import "package:locker/ui/pages/collection_page.dart";
class CollectionFlexGridViewWidget extends StatefulWidget {
final List<Collection> collections;
final Map<int, int> collectionFileCounts;
const CollectionFlexGridViewWidget({
super.key,
required this.collections,
required this.collectionFileCounts,
});
@override
State<CollectionFlexGridViewWidget> createState() =>
_CollectionFlexGridViewWidgetState();
}
class _CollectionFlexGridViewWidgetState
extends State<CollectionFlexGridViewWidget> {
late List<Collection> _displayedCollections;
late Map<int, int> _collectionFileCounts;
@override
void initState() {
super.initState();
_displayedCollections = widget.collections;
_collectionFileCounts = widget.collectionFileCounts;
}
@override
Widget build(BuildContext context) {
return MediaQuery.removePadding(
context: context,
removeBottom: true,
removeTop: true,
child: GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
childAspectRatio: 2.2,
),
itemCount: min(_displayedCollections.length, 4),
itemBuilder: (context, index) {
final collection = _displayedCollections[index];
final collectionName = collection.name ?? 'Unnamed Collection';
return GestureDetector(
onTap: () => _navigateToCollection(collection),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: getEnteColorScheme(context).fillFaint,
),
padding: const EdgeInsets.all(12),
child: Stack(
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
collectionName,
style: getEnteTextTheme(context).body.copyWith(
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.left,
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
const SizedBox(height: 4),
Text(
context.l10n
.items(_collectionFileCounts[collection.id] ?? 0),
style: getEnteTextTheme(context).small.copyWith(
color: Colors.grey[600],
),
textAlign: TextAlign.left,
),
],
),
if (collection.type == CollectionType.favorites)
Positioned(
top: 0,
right: 0,
child: Icon(
Icons.star,
color: getEnteColorScheme(context).primary500,
size: 18,
),
),
],
),
),
);
},
),
);
}
void _navigateToCollection(Collection collection) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => CollectionPage(collection: collection),
),
);
}
}

View File

@@ -1,67 +0,0 @@
import "package:ente_ui/theme/ente_theme.dart";
import 'package:flutter/material.dart';
class SectionTitle extends StatelessWidget {
final String? title;
final bool mutedTitle;
final Widget? titleWithBrand;
final EdgeInsetsGeometry? padding;
const SectionTitle({
this.title,
this.titleWithBrand,
this.mutedTitle = false,
super.key,
this.padding,
});
@override
Widget build(BuildContext context) {
Widget child;
if (titleWithBrand != null) {
child = titleWithBrand!;
} else if (title != null) {
child = Text(
title!,
style: getEnteTextTheme(context).h3Bold,
);
} else {
child = const SizedBox.shrink();
}
return child;
}
}
class SectionOptions extends StatelessWidget {
final Widget title;
final Widget? trailingWidget;
final VoidCallback? onTap;
const SectionOptions(
this.title, {
this.trailingWidget,
super.key,
this.onTap,
});
@override
Widget build(BuildContext context) {
if (trailingWidget != null) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
title,
trailingWidget!,
],
),
);
} else {
return Container(
child: title,
);
}
}
}

View File

@@ -1,53 +0,0 @@
import "package:ente_ui/theme/ente_theme.dart";
import "package:flutter/material.dart";
import "package:flutter/services.dart";
import "package:locker/l10n/l10n.dart";
class CopyButton extends StatefulWidget {
final String url;
const CopyButton({
super.key,
required this.url,
});
@override
State<CopyButton> createState() => _CopyButtonState();
}
class _CopyButtonState extends State<CopyButton> {
bool _isCopied = false;
@override
Widget build(BuildContext context) {
final colorScheme = getEnteColorScheme(context);
return IconButton(
onPressed: () async {
await Clipboard.setData(ClipboardData(text: widget.url));
setState(() {
_isCopied = true;
});
// Reset the state after 2 seconds
Future.delayed(const Duration(seconds: 2), () {
if (mounted) {
setState(() {
_isCopied = false;
});
}
});
},
icon: Icon(
_isCopied ? Icons.check : Icons.copy,
size: 16,
color: _isCopied ? colorScheme.primary500 : colorScheme.primary500,
),
iconSize: 16,
constraints: const BoxConstraints(),
padding: const EdgeInsets.all(4),
tooltip: _isCopied
? context.l10n.linkCopiedToClipboard
: context.l10n.copyLink,
);
}
}

View File

@@ -1,210 +0,0 @@
import "package:ente_events/event_bus.dart";
import "package:ente_ui/theme/ente_theme.dart";
import "package:flutter/material.dart";
import "package:locker/events/collections_updated_event.dart";
import "package:locker/l10n/l10n.dart";
import "package:locker/services/collections/models/collection.dart";
import "package:locker/services/collections/models/collection_view_type.dart";
import "package:locker/services/configuration.dart";
import "package:locker/ui/components/item_list_view.dart";
import "package:locker/ui/pages/collection_page.dart";
import "package:locker/utils/collection_actions.dart";
import "package:locker/utils/date_time_util.dart";
class CollectionRowWidget extends StatelessWidget {
final Collection collection;
final List<OverflowMenuAction>? overflowActions;
final bool isLastItem;
const CollectionRowWidget({
super.key,
required this.collection,
this.overflowActions,
this.isLastItem = false,
});
@override
Widget build(BuildContext context) {
final updateTime =
DateTime.fromMicrosecondsSinceEpoch(collection.updationTime);
return InkWell(
onTap: () => _openCollection(context),
child: Container(
padding: EdgeInsets.fromLTRB(16.0, 2, 16.0, isLastItem ? 8 : 2),
decoration: BoxDecoration(
border: isLastItem
? null
: Border(
bottom: BorderSide(
color: Theme.of(context).dividerColor.withAlpha(30),
width: 0.5,
),
),
),
child: Row(
children: [
Expanded(
flex: 2,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.folder_open,
color: collection.type == CollectionType.favorites
? getEnteColorScheme(context).primary500
: Colors.grey,
size: 20,
),
const SizedBox(width: 12),
Flexible(
child: Text(
collection.name ?? 'Unnamed Collection',
overflow: TextOverflow.ellipsis,
maxLines: 1,
style: getEnteTextTheme(context).body,
),
),
],
),
],
),
),
Expanded(
flex: 1,
child: Text(
formatDate(context, updateTime),
style: getEnteTextTheme(context).small.copyWith(
color: Theme.of(context).textTheme.bodySmall?.color,
),
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
),
PopupMenuButton<String>(
onSelected: (value) => _handleMenuAction(context, value),
icon: const Icon(
Icons.more_vert,
size: 20,
),
itemBuilder: (BuildContext context) {
return _buildPopupMenuItems(context);
},
),
],
),
),
);
}
List<PopupMenuItem<String>> _buildPopupMenuItems(BuildContext context) {
final collectionViewType =
getCollectionViewType(collection, Configuration.instance.getUserID()!);
if (overflowActions != null && overflowActions!.isNotEmpty) {
return overflowActions!
.map(
(action) => PopupMenuItem<String>(
value: action.id,
child: Row(
children: [
Icon(action.icon, size: 16),
const SizedBox(width: 8),
Text(action.label),
],
),
),
)
.toList();
} else {
return [
if (collectionViewType == CollectionViewType.ownedCollection ||
collectionViewType == CollectionViewType.hiddenOwnedCollection ||
collectionViewType == CollectionViewType.quickLink)
PopupMenuItem<String>(
value: 'edit',
child: Row(
children: [
const Icon(Icons.edit, size: 16),
const SizedBox(width: 8),
Text(context.l10n.edit),
],
),
),
if (collectionViewType == CollectionViewType.ownedCollection ||
collectionViewType == CollectionViewType.hiddenOwnedCollection ||
collectionViewType == CollectionViewType.quickLink)
PopupMenuItem<String>(
value: 'delete',
child: Row(
children: [
const Icon(Icons.delete, size: 16),
const SizedBox(width: 8),
Text(context.l10n.delete),
],
),
),
if (collectionViewType == CollectionViewType.sharedCollection)
PopupMenuItem<String>(
value: 'leave_collection',
child: Row(
children: [
const Icon(Icons.logout),
const SizedBox(width: 12),
Text(context.l10n.leaveCollection),
],
),
),
];
}
}
void _handleMenuAction(BuildContext context, String action) {
if (overflowActions != null && overflowActions!.isNotEmpty) {
final customAction = overflowActions!.firstWhere(
(a) => a.id == action,
orElse: () => throw StateError('Action not found'),
);
customAction.onTap(context, null, collection);
} else {
switch (action) {
case 'edit':
_editCollection(context);
break;
case 'delete':
_deleteCollection(context);
break;
case 'leave_collection':
_leaveCollection(context);
break;
}
}
}
void _editCollection(BuildContext context) {
CollectionActions.editCollection(context, collection);
}
void _deleteCollection(BuildContext context) {
CollectionActions.deleteCollection(context, collection);
}
void _openCollection(BuildContext context) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => CollectionPage(collection: collection),
),
);
}
Future<void> _leaveCollection(BuildContext context) async {
await CollectionActions.leaveCollection(
context,
collection,
onSuccess: () {
Bus.instance.fire(CollectionsUpdatedEvent());
},
);
}
}

View File

@@ -1,572 +0,0 @@
import "dart:io";
import "package:ente_ui/components/buttons/button_widget.dart";
import "package:ente_ui/components/buttons/models/button_type.dart";
import "package:ente_ui/theme/ente_theme.dart";
import "package:ente_ui/utils/dialog_util.dart";
import "package:ente_utils/share_utils.dart";
import "package:flutter/material.dart";
import "package:locker/l10n/l10n.dart";
import "package:locker/services/collections/collections_service.dart";
import "package:locker/services/collections/models/collection.dart";
import "package:locker/services/configuration.dart";
import "package:locker/services/files/download/file_downloader.dart";
import "package:locker/services/files/links/links_service.dart";
import "package:locker/services/files/sync/metadata_updater_service.dart";
import "package:locker/services/files/sync/models/file.dart";
import "package:locker/ui/components/button/copy_button.dart";
import "package:locker/ui/components/file_edit_dialog.dart";
import "package:locker/ui/components/item_list_view.dart";
import "package:locker/utils/date_time_util.dart";
import "package:locker/utils/file_icon_utils.dart";
import "package:locker/utils/snack_bar_utils.dart";
import "package:open_file/open_file.dart";
class FileRowWidget extends StatelessWidget {
final EnteFile file;
final List<Collection> collections;
final List<OverflowMenuAction>? overflowActions;
final bool isLastItem;
const FileRowWidget({
super.key,
required this.file,
required this.collections,
this.overflowActions,
this.isLastItem = false,
});
@override
Widget build(BuildContext context) {
final updateTime = file.updationTime != null
? DateTime.fromMicrosecondsSinceEpoch(file.updationTime!)
: (file.modificationTime != null
? DateTime.fromMillisecondsSinceEpoch(file.modificationTime!)
: (file.creationTime != null
? DateTime.fromMillisecondsSinceEpoch(file.creationTime!)
: DateTime.now()));
return InkWell(
onTap: () => _openFile(context),
child: Container(
padding: EdgeInsets.fromLTRB(16.0, 2, 16.0, isLastItem ? 8 : 2),
decoration: BoxDecoration(
border: isLastItem
? null
: Border(
bottom: BorderSide(
color: Theme.of(context).dividerColor.withOpacity(0.3),
width: 0.5,
),
),
),
child: Row(
children: [
Expanded(
flex: 2,
child: Padding(
padding: const EdgeInsets.only(right: 16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
FileIconUtils.getFileIcon(file.displayName),
color:
FileIconUtils.getFileIconColor(file.displayName),
size: 20,
),
const SizedBox(width: 12),
Flexible(
child: Text(
file.displayName,
overflow: TextOverflow.ellipsis,
maxLines: 1,
style: getEnteTextTheme(context).body,
),
),
],
),
],
),
),
),
Expanded(
flex: 1,
child: Text(
formatDate(context, updateTime),
style: getEnteTextTheme(context).small.copyWith(
color: Theme.of(context).textTheme.bodySmall?.color,
),
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
),
PopupMenuButton<String>(
onSelected: (value) => _handleMenuAction(context, value),
icon: const Icon(
Icons.more_vert,
size: 20,
),
itemBuilder: (BuildContext context) {
if (overflowActions != null && overflowActions!.isNotEmpty) {
return overflowActions!
.map(
(action) => PopupMenuItem<String>(
value: action.id,
child: Row(
children: [
Icon(action.icon, size: 16),
const SizedBox(width: 8),
Text(action.label),
],
),
),
)
.toList();
} else {
return [
PopupMenuItem<String>(
value: 'edit',
child: Row(
children: [
const Icon(Icons.edit, size: 16),
const SizedBox(width: 8),
Text(context.l10n.edit),
],
),
),
PopupMenuItem<String>(
value: 'share_link',
child: Row(
children: [
const Icon(Icons.share, size: 16),
const SizedBox(width: 8),
Text(context.l10n.share),
],
),
),
PopupMenuItem<String>(
value: 'delete',
child: Row(
children: [
const Icon(Icons.delete, size: 16),
const SizedBox(width: 8),
Text(context.l10n.delete),
],
),
),
];
}
},
),
],
),
),
);
}
void _handleMenuAction(BuildContext context, String action) {
if (overflowActions != null && overflowActions!.isNotEmpty) {
final customAction = overflowActions!.firstWhere(
(a) => a.id == action,
orElse: () => throw StateError('Action not found'),
);
customAction.onTap(context, file, null);
} else {
switch (action) {
case 'edit':
_showEditDialog(context);
break;
case 'share_link':
_shareLink(context);
break;
case 'delete':
_showDeleteConfirmationDialog(context);
break;
}
}
}
Future<void> _shareLink(BuildContext context) async {
final dialog = createProgressDialog(
context,
context.l10n.creatingShareLink,
isDismissible: false,
);
try {
await dialog.show();
// Get or create the share link
final shareableLink = await LinksService.instance.getOrCreateLink(file);
await dialog.hide();
// Show the link dialog with copy and delete options
if (context.mounted) {
await _showShareLinkDialog(
context,
shareableLink.fullURL!,
shareableLink.linkID,
);
}
} catch (e) {
await dialog.hide();
if (context.mounted) {
SnackBarUtils.showWarningSnackBar(
context,
'${context.l10n.failedToCreateShareLink}: ${e.toString()}',
);
}
}
}
Future<void> _showShareLinkDialog(
BuildContext context,
String url,
String linkID,
) async {
final colorScheme = getEnteColorScheme(context);
final textTheme = getEnteTextTheme(context);
// Capture the root context (with Scaffold) before showing dialog
final rootContext = context;
await showDialog<void>(
context: context,
builder: (BuildContext dialogContext) {
return StatefulBuilder(
builder: (context, setState) {
return AlertDialog(
title: Text(
dialogContext.l10n.share,
style: textTheme.largeBold,
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
dialogContext.l10n.shareThisLink,
style: textTheme.body,
),
const SizedBox(height: 20),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colorScheme.fillFaint,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: colorScheme.strokeFaint),
),
child: Row(
children: [
Expanded(
child: SelectableText(
url,
style: textTheme.small,
),
),
const SizedBox(width: 8),
CopyButton(url: url),
],
),
),
],
),
actions: [
TextButton(
onPressed: () async {
Navigator.of(context).pop();
await _deleteShareLink(rootContext, file.uploadedFileID!);
},
child: Text(
dialogContext.l10n.deleteLink,
style:
textTheme.body.copyWith(color: colorScheme.warning500),
),
),
TextButton(
onPressed: () async {
Navigator.of(dialogContext).pop();
// Use system share sheet to share the URL
await shareText(
url,
context: rootContext,
);
},
child: Text(
dialogContext.l10n.shareLink,
style:
textTheme.body.copyWith(color: colorScheme.primary500),
),
),
],
);
},
);
},
);
}
Future<void> _deleteShareLink(BuildContext context, int fileID) async {
final result = await showChoiceDialog(
context,
title: context.l10n.deleteShareLinkDialogTitle,
body: context.l10n.deleteShareLinkConfirmation,
firstButtonLabel: context.l10n.delete,
secondButtonLabel: context.l10n.cancel,
firstButtonType: ButtonType.critical,
isCritical: true,
);
if (result?.action == ButtonAction.first && context.mounted) {
final dialog = createProgressDialog(
context,
context.l10n.deletingShareLink,
isDismissible: false,
);
try {
await dialog.show();
await LinksService.instance.deleteLink(fileID);
await dialog.hide();
if (context.mounted) {
SnackBarUtils.showInfoSnackBar(
context,
context.l10n.shareLinkDeletedSuccessfully,
);
}
} catch (e) {
await dialog.hide();
if (context.mounted) {
SnackBarUtils.showWarningSnackBar(
context,
'${context.l10n.failedToDeleteShareLink}: ${e.toString()}',
);
}
}
}
}
Future<void> _showDeleteConfirmationDialog(BuildContext context) async {
final result = await showChoiceDialog(
context,
title: context.l10n.deleteFile,
body: context.l10n.deleteFileConfirmation(file.displayName),
firstButtonLabel: context.l10n.delete,
secondButtonLabel: context.l10n.cancel,
firstButtonType: ButtonType.critical,
isCritical: true,
);
if (result?.action == ButtonAction.first && context.mounted) {
await _deleteFile(context);
}
}
Future<void> _deleteFile(BuildContext context) async {
final dialog = createProgressDialog(
context,
context.l10n.deletingFile,
isDismissible: false,
);
try {
await dialog.show();
final collections =
await CollectionService.instance.getCollectionsForFile(file);
if (collections.isNotEmpty) {
await CollectionService.instance.trashFile(file, collections.first);
}
await dialog.hide();
SnackBarUtils.showInfoSnackBar(
context,
context.l10n.fileDeletedSuccessfully,
);
} catch (e) {
await dialog.hide();
SnackBarUtils.showWarningSnackBar(
context,
context.l10n.failedToDeleteFile(e.toString()),
);
}
}
Future<void> _showEditDialog(BuildContext context) async {
final allCollections = await CollectionService.instance.getCollections();
allCollections.removeWhere(
(c) => c.type == CollectionType.uncategorized,
);
final result = await showFileEditDialog(
context,
file: file,
collections: allCollections,
);
if (result != null && context.mounted) {
List<Collection> currentCollections;
try {
currentCollections =
await CollectionService.instance.getCollectionsForFile(file);
} catch (e) {
currentCollections = <Collection>[];
}
final currentCollectionsSet = currentCollections.toSet();
final newCollectionsSet = result.selectedCollections.toSet();
final collectionsToAdd =
newCollectionsSet.difference(currentCollectionsSet).toList();
final collectionsToRemove =
currentCollectionsSet.difference(newCollectionsSet).toList();
final currentTitle = file.displayName;
final currentCaption = file.caption ?? '';
final hasMetadataChanged =
result.title != currentTitle || result.caption != currentCaption;
if (hasMetadataChanged || currentCollectionsSet != newCollectionsSet) {
final dialog = createProgressDialog(
context,
context.l10n.pleaseWait,
isDismissible: false,
);
await dialog.show();
try {
final List<Future<void>> apiCalls = [];
for (final collection in collectionsToAdd) {
apiCalls.add(
CollectionService.instance.addToCollection(collection, file),
);
}
await Future.wait(apiCalls);
apiCalls.clear();
for (final collection in collectionsToRemove) {
apiCalls.add(
CollectionService.instance
.move(file, collection, newCollectionsSet.first),
);
}
if (hasMetadataChanged) {
apiCalls.add(
MetadataUpdaterService.instance
.editFileNameAndCaption(file, result.title, result.caption),
);
}
await Future.wait(apiCalls);
await dialog.hide();
SnackBarUtils.showInfoSnackBar(
context,
context.l10n.fileUpdatedSuccessfully,
);
} catch (e) {
await dialog.hide();
SnackBarUtils.showWarningSnackBar(
context,
context.l10n.failedToUpdateFile(e.toString()),
);
}
} else {
SnackBarUtils.showWarningSnackBar(
context,
context.l10n.noChangesWereMade,
);
}
}
}
Future<void> _openFile(BuildContext context) async {
if (file.localPath != null) {
final localFile = File(file.localPath!);
if (await localFile.exists()) {
await _launchFile(context, localFile, file.displayName);
return;
}
}
final String cachedFilePath =
"${Configuration.instance.getCacheDirectory()}${file.displayName}";
final File cachedFile = File(cachedFilePath);
if (await cachedFile.exists()) {
await _launchFile(context, cachedFile, file.displayName);
return;
}
final dialog = createProgressDialog(
context,
context.l10n.downloading,
isDismissible: false,
);
try {
await dialog.show();
final fileKey = await CollectionService.instance.getFileKey(file);
final decryptedFile = await downloadAndDecrypt(
file,
fileKey,
progressCallback: (downloaded, total) {
if (total > 0 && downloaded >= 0) {
final percentage =
((downloaded / total) * 100).clamp(0, 100).round();
dialog.update(
message: context.l10n.downloadingProgress(percentage),
);
} else {
dialog.update(message: context.l10n.downloading);
}
},
shouldUseCache: true,
);
await dialog.hide();
if (decryptedFile != null) {
await _launchFile(context, decryptedFile, file.displayName);
} else {
await showErrorDialog(
context,
context.l10n.downloadFailed,
context.l10n.failedToDownloadOrDecrypt,
);
}
} catch (e) {
await dialog.hide();
await showErrorDialog(
context,
context.l10n.errorOpeningFile,
context.l10n.errorOpeningFileMessage(e.toString()),
);
}
}
Future<void> _launchFile(
BuildContext context,
File file,
String fileName,
) async {
try {
await OpenFile.open(file.path);
} catch (e) {
await showErrorDialog(
context,
context.l10n.errorOpeningFile,
context.l10n.couldNotOpenFile(e.toString()),
);
}
}
}

View File

@@ -1,12 +1,28 @@
import 'dart:io';
import 'package:ente_ui/components/buttons/button_widget.dart';
import 'package:ente_ui/components/buttons/models/button_type.dart';
import 'package:ente_ui/theme/ente_theme.dart';
import 'package:flutter/material.dart';
import 'package:ente_ui/utils/dialog_util.dart';
import 'package:ente_utils/share_utils.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:locker/l10n/l10n.dart';
import 'package:locker/services/collections/collections_service.dart';
import 'package:locker/services/collections/models/collection.dart';
import 'package:locker/services/configuration.dart';
import 'package:locker/services/files/download/file_downloader.dart';
import 'package:locker/services/files/links/links_service.dart';
import 'package:locker/services/files/sync/metadata_updater_service.dart';
import 'package:locker/services/files/sync/models/file.dart';
import "package:locker/ui/components/collection_row_widget.dart";
import "package:locker/ui/components/file_row_widget.dart";
import 'package:locker/ui/components/file_edit_dialog.dart';
import 'package:locker/ui/pages/collection_page.dart';
import 'package:locker/utils/collection_actions.dart';
import 'package:locker/utils/collection_sort_util.dart';
import 'package:locker/utils/date_time_util.dart';
import 'package:locker/utils/file_icon_utils.dart';
import 'package:locker/utils/snack_bar_utils.dart';
import 'package:open_file/open_file.dart';
class OverflowMenuAction {
final String id;
@@ -384,6 +400,767 @@ class ListItemWidget extends StatelessWidget {
}
}
class CollectionRowWidget extends StatelessWidget {
final Collection collection;
final List<OverflowMenuAction>? overflowActions;
final bool isLastItem;
const CollectionRowWidget({
super.key,
required this.collection,
this.overflowActions,
this.isLastItem = false,
});
@override
Widget build(BuildContext context) {
final updateTime =
DateTime.fromMicrosecondsSinceEpoch(collection.updationTime);
return InkWell(
onTap: () => _openCollection(context),
child: Container(
padding: EdgeInsets.fromLTRB(16.0, 2, 16.0, isLastItem ? 8 : 2),
decoration: BoxDecoration(
border: isLastItem
? null
: Border(
bottom: BorderSide(
color: Theme.of(context).dividerColor.withOpacity(0.3),
width: 0.5,
),
),
),
child: Row(
children: [
Expanded(
flex: 2,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.folder_open,
color: collection.type == CollectionType.favorites
? getEnteColorScheme(context).primary500
: Colors.grey,
size: 20,
),
const SizedBox(width: 12),
Flexible(
child: Text(
collection.name ?? 'Unnamed Collection',
overflow: TextOverflow.ellipsis,
maxLines: 1,
style: getEnteTextTheme(context).body,
),
),
],
),
],
),
),
Expanded(
flex: 1,
child: Text(
formatDate(context, updateTime),
style: getEnteTextTheme(context).small.copyWith(
color: Theme.of(context).textTheme.bodySmall?.color,
),
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
),
PopupMenuButton<String>(
onSelected: (value) => _handleMenuAction(context, value),
icon: const Icon(
Icons.more_vert,
size: 20,
),
itemBuilder: (BuildContext context) {
if (overflowActions != null && overflowActions!.isNotEmpty) {
return overflowActions!
.map(
(action) => PopupMenuItem<String>(
value: action.id,
child: Row(
children: [
Icon(action.icon, size: 16),
const SizedBox(width: 8),
Text(action.label),
],
),
),
)
.toList();
} else {
return [
PopupMenuItem<String>(
value: 'edit',
child: Row(
children: [
const Icon(Icons.edit, size: 16),
const SizedBox(width: 8),
Text(context.l10n.edit),
],
),
),
PopupMenuItem<String>(
value: 'delete',
child: Row(
children: [
const Icon(Icons.delete, size: 16),
const SizedBox(width: 8),
Text(context.l10n.delete),
],
),
),
];
}
},
),
],
),
),
);
}
void _handleMenuAction(BuildContext context, String action) {
if (overflowActions != null && overflowActions!.isNotEmpty) {
final customAction = overflowActions!.firstWhere(
(a) => a.id == action,
orElse: () => throw StateError('Action not found'),
);
customAction.onTap(context, null, collection);
} else {
switch (action) {
case 'edit':
_editCollection(context);
break;
case 'delete':
_deleteCollection(context);
break;
}
}
}
void _editCollection(BuildContext context) {
CollectionActions.editCollection(context, collection);
}
void _deleteCollection(BuildContext context) {
CollectionActions.deleteCollection(context, collection);
}
void _openCollection(BuildContext context) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => CollectionPage(collection: collection),
),
);
}
}
class FileRowWidget extends StatelessWidget {
final EnteFile file;
final List<Collection> collections;
final List<OverflowMenuAction>? overflowActions;
final bool isLastItem;
const FileRowWidget({
super.key,
required this.file,
required this.collections,
this.overflowActions,
this.isLastItem = false,
});
@override
Widget build(BuildContext context) {
final updateTime = file.updationTime != null
? DateTime.fromMicrosecondsSinceEpoch(file.updationTime!)
: (file.modificationTime != null
? DateTime.fromMillisecondsSinceEpoch(file.modificationTime!)
: (file.creationTime != null
? DateTime.fromMillisecondsSinceEpoch(file.creationTime!)
: DateTime.now()));
return InkWell(
onTap: () => _openFile(context),
child: Container(
padding: EdgeInsets.fromLTRB(16.0, 2, 16.0, isLastItem ? 8 : 2),
decoration: BoxDecoration(
border: isLastItem
? null
: Border(
bottom: BorderSide(
color: Theme.of(context).dividerColor.withOpacity(0.3),
width: 0.5,
),
),
),
child: Row(
children: [
Expanded(
flex: 2,
child: Padding(
padding: const EdgeInsets.only(right: 16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
FileIconUtils.getFileIcon(file.displayName),
color:
FileIconUtils.getFileIconColor(file.displayName),
size: 20,
),
const SizedBox(width: 12),
Flexible(
child: Text(
file.displayName,
overflow: TextOverflow.ellipsis,
maxLines: 1,
style: getEnteTextTheme(context).body,
),
),
],
),
],
),
),
),
Expanded(
flex: 1,
child: Text(
formatDate(context, updateTime),
style: getEnteTextTheme(context).small.copyWith(
color: Theme.of(context).textTheme.bodySmall?.color,
),
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
),
PopupMenuButton<String>(
onSelected: (value) => _handleMenuAction(context, value),
icon: const Icon(
Icons.more_vert,
size: 20,
),
itemBuilder: (BuildContext context) {
if (overflowActions != null && overflowActions!.isNotEmpty) {
return overflowActions!
.map(
(action) => PopupMenuItem<String>(
value: action.id,
child: Row(
children: [
Icon(action.icon, size: 16),
const SizedBox(width: 8),
Text(action.label),
],
),
),
)
.toList();
} else {
return [
PopupMenuItem<String>(
value: 'edit',
child: Row(
children: [
const Icon(Icons.edit, size: 16),
const SizedBox(width: 8),
Text(context.l10n.edit),
],
),
),
PopupMenuItem<String>(
value: 'share_link',
child: Row(
children: [
const Icon(Icons.share, size: 16),
const SizedBox(width: 8),
Text(context.l10n.share),
],
),
),
PopupMenuItem<String>(
value: 'delete',
child: Row(
children: [
const Icon(Icons.delete, size: 16),
const SizedBox(width: 8),
Text(context.l10n.delete),
],
),
),
];
}
},
),
],
),
),
);
}
void _handleMenuAction(BuildContext context, String action) {
if (overflowActions != null && overflowActions!.isNotEmpty) {
final customAction = overflowActions!.firstWhere(
(a) => a.id == action,
orElse: () => throw StateError('Action not found'),
);
customAction.onTap(context, file, null);
} else {
switch (action) {
case 'edit':
_showEditDialog(context);
break;
case 'share_link':
_shareLink(context);
break;
case 'delete':
_showDeleteConfirmationDialog(context);
break;
}
}
}
Future<void> _shareLink(BuildContext context) async {
final dialog = createProgressDialog(
context,
context.l10n.creatingShareLink,
isDismissible: false,
);
try {
await dialog.show();
// Get or create the share link
final shareableLink = await LinksService.instance.getOrCreateLink(file);
await dialog.hide();
// Show the link dialog with copy and delete options
if (context.mounted) {
await _showShareLinkDialog(
context,
shareableLink.fullURL!,
shareableLink.linkID,
);
}
} catch (e) {
await dialog.hide();
if (context.mounted) {
SnackBarUtils.showWarningSnackBar(
context,
'${context.l10n.failedToCreateShareLink}: ${e.toString()}',
);
}
}
}
Future<void> _showShareLinkDialog(
BuildContext context,
String url,
String linkID,
) async {
final colorScheme = getEnteColorScheme(context);
final textTheme = getEnteTextTheme(context);
// Capture the root context (with Scaffold) before showing dialog
final rootContext = context;
await showDialog<void>(
context: context,
builder: (BuildContext dialogContext) {
return StatefulBuilder(
builder: (context, setState) {
return AlertDialog(
title: Text(
dialogContext.l10n.share,
style: textTheme.largeBold,
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
dialogContext.l10n.shareThisLink,
style: textTheme.body,
),
const SizedBox(height: 20),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colorScheme.fillFaint,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: colorScheme.strokeFaint),
),
child: Row(
children: [
Expanded(
child: SelectableText(
url,
style: textTheme.small,
),
),
const SizedBox(width: 8),
_CopyButton(
url: url,
),
],
),
),
],
),
actions: [
TextButton(
onPressed: () async {
Navigator.of(context).pop();
await _deleteShareLink(rootContext, file.uploadedFileID!);
},
child: Text(
dialogContext.l10n.deleteLink,
style:
textTheme.body.copyWith(color: colorScheme.warning500),
),
),
TextButton(
onPressed: () async {
Navigator.of(dialogContext).pop();
// Use system share sheet to share the URL
await shareText(
url,
context: rootContext,
);
},
child: Text(
dialogContext.l10n.shareLink,
style:
textTheme.body.copyWith(color: colorScheme.primary500),
),
),
],
);
},
);
},
);
}
Future<void> _deleteShareLink(BuildContext context, int fileID) async {
final result = await showChoiceDialog(
context,
title: context.l10n.deleteShareLinkDialogTitle,
body: context.l10n.deleteShareLinkConfirmation,
firstButtonLabel: context.l10n.delete,
secondButtonLabel: context.l10n.cancel,
firstButtonType: ButtonType.critical,
isCritical: true,
);
if (result?.action == ButtonAction.first && context.mounted) {
final dialog = createProgressDialog(
context,
context.l10n.deletingShareLink,
isDismissible: false,
);
try {
await dialog.show();
await LinksService.instance.deleteLink(fileID);
await dialog.hide();
if (context.mounted) {
SnackBarUtils.showInfoSnackBar(
context,
context.l10n.shareLinkDeletedSuccessfully,
);
}
} catch (e) {
await dialog.hide();
if (context.mounted) {
SnackBarUtils.showWarningSnackBar(
context,
'${context.l10n.failedToDeleteShareLink}: ${e.toString()}',
);
}
}
}
}
Future<void> _showDeleteConfirmationDialog(BuildContext context) async {
final result = await showChoiceDialog(
context,
title: context.l10n.deleteFile,
body: context.l10n.deleteFileConfirmation(file.displayName),
firstButtonLabel: context.l10n.delete,
secondButtonLabel: context.l10n.cancel,
firstButtonType: ButtonType.critical,
isCritical: true,
);
if (result?.action == ButtonAction.first && context.mounted) {
await _deleteFile(context);
}
}
Future<void> _deleteFile(BuildContext context) async {
final dialog = createProgressDialog(
context,
context.l10n.deletingFile,
isDismissible: false,
);
try {
await dialog.show();
final collections =
await CollectionService.instance.getCollectionsForFile(file);
if (collections.isNotEmpty) {
await CollectionService.instance.trashFile(file, collections.first);
}
await dialog.hide();
SnackBarUtils.showInfoSnackBar(
context,
context.l10n.fileDeletedSuccessfully,
);
} catch (e) {
await dialog.hide();
SnackBarUtils.showWarningSnackBar(
context,
context.l10n.failedToDeleteFile(e.toString()),
);
}
}
Future<void> _showEditDialog(BuildContext context) async {
final allCollections = await CollectionService.instance.getCollections();
allCollections.removeWhere(
(c) => c.type == CollectionType.uncategorized,
);
final result = await showFileEditDialog(
context,
file: file,
collections: allCollections,
);
if (result != null && context.mounted) {
List<Collection> currentCollections;
try {
currentCollections =
await CollectionService.instance.getCollectionsForFile(file);
} catch (e) {
currentCollections = <Collection>[];
}
final currentCollectionsSet = currentCollections.toSet();
final newCollectionsSet = result.selectedCollections.toSet();
final collectionsToAdd =
newCollectionsSet.difference(currentCollectionsSet).toList();
final collectionsToRemove =
currentCollectionsSet.difference(newCollectionsSet).toList();
final currentTitle = file.displayName;
final currentCaption = file.caption ?? '';
final hasMetadataChanged =
result.title != currentTitle || result.caption != currentCaption;
if (hasMetadataChanged || currentCollectionsSet != newCollectionsSet) {
final dialog = createProgressDialog(
context,
context.l10n.pleaseWait,
isDismissible: false,
);
await dialog.show();
try {
final List<Future<void>> apiCalls = [];
for (final collection in collectionsToAdd) {
apiCalls.add(
CollectionService.instance.addToCollection(collection, file),
);
}
await Future.wait(apiCalls);
apiCalls.clear();
for (final collection in collectionsToRemove) {
apiCalls.add(
CollectionService.instance
.move(file, collection, newCollectionsSet.first),
);
}
if (hasMetadataChanged) {
apiCalls.add(
MetadataUpdaterService.instance
.editFileNameAndCaption(file, result.title, result.caption),
);
}
await Future.wait(apiCalls);
await dialog.hide();
SnackBarUtils.showInfoSnackBar(
context,
context.l10n.fileUpdatedSuccessfully,
);
} catch (e) {
await dialog.hide();
SnackBarUtils.showWarningSnackBar(
context,
context.l10n.failedToUpdateFile(e.toString()),
);
}
} else {
SnackBarUtils.showWarningSnackBar(
context,
context.l10n.noChangesWereMade,
);
}
}
}
Future<void> _openFile(BuildContext context) async {
if (file.localPath != null) {
final localFile = File(file.localPath!);
if (await localFile.exists()) {
await _launchFile(context, localFile, file.displayName);
return;
}
}
final String cachedFilePath =
"${Configuration.instance.getCacheDirectory()}${file.displayName}";
final File cachedFile = File(cachedFilePath);
if (await cachedFile.exists()) {
await _launchFile(context, cachedFile, file.displayName);
return;
}
final dialog = createProgressDialog(
context,
context.l10n.downloading,
isDismissible: false,
);
try {
await dialog.show();
final fileKey = await CollectionService.instance.getFileKey(file);
final decryptedFile = await downloadAndDecrypt(
file,
fileKey,
progressCallback: (downloaded, total) {
if (total > 0 && downloaded >= 0) {
final percentage =
((downloaded / total) * 100).clamp(0, 100).round();
dialog.update(
message: context.l10n.downloadingProgress(percentage),
);
} else {
dialog.update(message: context.l10n.downloading);
}
},
shouldUseCache: true,
);
await dialog.hide();
if (decryptedFile != null) {
await _launchFile(context, decryptedFile, file.displayName);
} else {
await showErrorDialog(
context,
context.l10n.downloadFailed,
context.l10n.failedToDownloadOrDecrypt,
);
}
} catch (e) {
await dialog.hide();
await showErrorDialog(
context,
context.l10n.errorOpeningFile,
context.l10n.errorOpeningFileMessage(e.toString()),
);
}
}
Future<void> _launchFile(
BuildContext context,
File file,
String fileName,
) async {
try {
await OpenFile.open(file.path);
} catch (e) {
await showErrorDialog(
context,
context.l10n.errorOpeningFile,
context.l10n.couldNotOpenFile(e.toString()),
);
}
}
}
class _CopyButton extends StatefulWidget {
final String url;
const _CopyButton({
required this.url,
});
@override
State<_CopyButton> createState() => _CopyButtonState();
}
class _CopyButtonState extends State<_CopyButton> {
bool _isCopied = false;
@override
Widget build(BuildContext context) {
final colorScheme = getEnteColorScheme(context);
return IconButton(
onPressed: () async {
await Clipboard.setData(ClipboardData(text: widget.url));
setState(() {
_isCopied = true;
});
// Reset the state after 2 seconds
Future.delayed(const Duration(seconds: 2), () {
if (mounted) {
setState(() {
_isCopied = false;
});
}
});
},
icon: Icon(
_isCopied ? Icons.check : Icons.copy,
size: 16,
color: _isCopied ? colorScheme.primary500 : colorScheme.primary500,
),
iconSize: 16,
constraints: const BoxConstraints(),
padding: const EdgeInsets.all(4),
tooltip: _isCopied
? context.l10n.linkCopiedToClipboard
: context.l10n.copyLink,
);
}
}
class FileListViewHelpers {
static Widget createSearchEmptyState({
required String searchQuery,

View File

@@ -18,19 +18,8 @@ import 'package:locker/ui/pages/trash_page.dart';
import 'package:locker/utils/collection_sort_util.dart';
import 'package:logging/logging.dart';
enum UISectionType {
incomingCollections,
outgoingCollections,
homeCollections,
}
class AllCollectionsPage extends StatefulWidget {
final UISectionType viewType;
const AllCollectionsPage({
super.key,
this.viewType = UISectionType.homeCollections,
});
const AllCollectionsPage({super.key});
@override
State<AllCollectionsPage> createState() => _AllCollectionsPageState();
@@ -45,8 +34,6 @@ class _AllCollectionsPageState extends State<AllCollectionsPage>
List<EnteFile> _allFiles = [];
bool _isLoading = true;
String? _error;
bool showTrash = false;
bool showUncategorized = false;
final _logger = Logger("AllCollectionsPage");
@override
@@ -81,10 +68,6 @@ class _AllCollectionsPageState extends State<AllCollectionsPage>
Bus.instance.on<CollectionsUpdatedEvent>().listen((event) async {
await _loadCollections();
});
if (widget.viewType == UISectionType.homeCollections) {
showTrash = true;
showUncategorized = true;
}
}
Future<void> _loadCollections() async {
@@ -94,19 +77,7 @@ class _AllCollectionsPageState extends State<AllCollectionsPage>
});
try {
List<Collection> collections = [];
if (widget.viewType == UISectionType.homeCollections) {
collections = await CollectionService.instance.getCollections();
} else {
final sharedCollections =
await CollectionService.instance.getSharedCollections();
if (widget.viewType == UISectionType.outgoingCollections) {
collections = sharedCollections.outgoing;
} else if (widget.viewType == UISectionType.incomingCollections) {
collections = sharedCollections.incoming;
}
}
final collections = await CollectionService.instance.getCollections();
final regularCollections = <Collection>[];
Collection? uncategorized;
@@ -123,12 +94,8 @@ class _AllCollectionsPageState extends State<AllCollectionsPage>
_allCollections = List.from(collections);
_sortedCollections = List.from(regularCollections);
_uncategorizedCollection =
widget.viewType == UISectionType.homeCollections
? uncategorized
: null;
_uncategorizedFileCount = uncategorized != null &&
widget.viewType == UISectionType.homeCollections
_uncategorizedCollection = uncategorized;
_uncategorizedFileCount = uncategorized != null
? (await CollectionService.instance
.getFilesInCollection(uncategorized))
.length
@@ -155,7 +122,7 @@ class _AllCollectionsPageState extends State<AllCollectionsPage>
child: Scaffold(
appBar: AppBar(
leading: buildSearchLeading(),
title: Text(_getTitle(context)),
title: Text(context.l10n.collections),
centerTitle: false,
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
foregroundColor: Theme.of(context).textTheme.bodyLarge?.color,
@@ -270,11 +237,9 @@ class _AllCollectionsPageState extends State<AllCollectionsPage>
enableSorting: true,
),
),
if (!isSearchActive &&
_uncategorizedCollection != null &&
showUncategorized)
if (!isSearchActive && _uncategorizedCollection != null)
_buildUncategorizedHook(),
if (showTrash) _buildTrashHook(),
_buildTrashHook(),
],
),
);
@@ -289,9 +254,9 @@ class _AllCollectionsPageState extends State<AllCollectionsPage>
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 16.0),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface.withAlpha(30),
color: Theme.of(context).colorScheme.surface.withOpacity(0.3),
border: Border.all(
color: Theme.of(context).dividerColor.withAlpha(50),
color: Theme.of(context).dividerColor.withOpacity(0.5),
width: 0.5,
),
borderRadius: BorderRadius.circular(12.0),
@@ -300,8 +265,11 @@ class _AllCollectionsPageState extends State<AllCollectionsPage>
children: [
Icon(
Icons.delete_outline,
color:
Theme.of(context).textTheme.bodyLarge?.color?.withAlpha(70),
color: Theme.of(context)
.textTheme
.bodyLarge
?.color
?.withOpacity(0.7),
size: 22,
),
const SizedBox(width: 12),
@@ -319,7 +287,7 @@ class _AllCollectionsPageState extends State<AllCollectionsPage>
.textTheme
.bodyMedium
?.color
?.withAlpha(60),
?.withOpacity(0.6),
size: 20,
),
],
@@ -358,9 +326,9 @@ class _AllCollectionsPageState extends State<AllCollectionsPage>
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 16.0),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface.withAlpha(30),
color: Theme.of(context).colorScheme.surface.withOpacity(0.3),
border: Border.all(
color: Theme.of(context).dividerColor.withAlpha(50),
color: Theme.of(context).dividerColor.withOpacity(0.5),
width: 0.5,
),
borderRadius: BorderRadius.circular(12.0),
@@ -369,8 +337,11 @@ class _AllCollectionsPageState extends State<AllCollectionsPage>
children: [
Icon(
Icons.folder_open_outlined,
color:
Theme.of(context).textTheme.bodyLarge?.color?.withAlpha(70),
color: Theme.of(context)
.textTheme
.bodyLarge
?.color
?.withOpacity(0.7),
size: 22,
),
const SizedBox(width: 12),
@@ -392,7 +363,7 @@ class _AllCollectionsPageState extends State<AllCollectionsPage>
.textTheme
.bodySmall
?.color
?.withAlpha(50),
?.withOpacity(0.5),
),
),
const SizedBox(width: 8),
@@ -403,7 +374,7 @@ class _AllCollectionsPageState extends State<AllCollectionsPage>
.textTheme
.bodySmall
?.color
?.withAlpha(70),
?.withOpacity(0.7),
),
),
],
@@ -416,7 +387,7 @@ class _AllCollectionsPageState extends State<AllCollectionsPage>
.textTheme
.bodyMedium
?.color
?.withAlpha(60),
?.withOpacity(0.6),
size: 20,
),
],
@@ -434,15 +405,4 @@ class _AllCollectionsPageState extends State<AllCollectionsPage>
),
);
}
String _getTitle(BuildContext context) {
switch (widget.viewType) {
case UISectionType.homeCollections:
return context.l10n.collections;
case UISectionType.outgoingCollections:
return context.l10n.sharedByYou;
case UISectionType.incomingCollections:
return context.l10n.sharedWithYou;
}
}
}

View File

@@ -1,27 +1,17 @@
import "dart:async";
import 'package:ente_events/event_bus.dart';
import 'package:ente_ui/theme/ente_theme.dart';
import "package:ente_ui/utils/dialog_util.dart";
import "package:ente_utils/navigation_util.dart";
import 'package:flutter/material.dart';
import 'package:locker/events/collections_updated_event.dart';
import 'package:locker/l10n/l10n.dart';
import 'package:locker/services/collections/collections_service.dart';
import 'package:locker/services/collections/models/collection.dart';
import "package:locker/services/collections/models/collection_view_type.dart";
import "package:locker/services/configuration.dart";
import 'package:locker/services/files/sync/models/file.dart';
import 'package:locker/ui/components/item_list_view.dart';
import 'package:locker/ui/components/search_result_view.dart';
import 'package:locker/ui/mixins/search_mixin.dart';
import 'package:locker/ui/pages/home_page.dart';
import 'package:locker/ui/pages/uploader_page.dart';
import "package:locker/ui/sharing/album_participants_page.dart";
import "package:locker/ui/sharing/manage_links_widget.dart";
import "package:locker/ui/sharing/share_collection_page.dart";
import 'package:locker/utils/collection_actions.dart';
import "package:logging/logging.dart";
class CollectionPage extends UploaderPage {
final Collection collection;
@@ -37,16 +27,9 @@ class CollectionPage extends UploaderPage {
class _CollectionPageState extends UploaderPageState<CollectionPage>
with SearchMixin {
final _logger = Logger("CollectionPage");
late StreamSubscription<CollectionsUpdatedEvent>
_collectionUpdateSubscription;
late Collection _collection;
List<EnteFile> _files = [];
List<EnteFile> _filteredFiles = [];
late CollectionViewType collectionViewType;
bool isQuickLink = false;
bool showFAB = true;
@override
void onFileUploadComplete() {
@@ -68,9 +51,7 @@ class _CollectionPageState extends UploaderPageState<CollectionPage>
@override
void onSearchResultsChanged(
List<Collection> collections,
List<EnteFile> files,
) {
List<Collection> collections, List<EnteFile> files,) {
setState(() {
_filteredFiles = files;
});
@@ -85,12 +66,6 @@ class _CollectionPageState extends UploaderPageState<CollectionPage>
}
}
@override
void dispose() {
_collectionUpdateSubscription.cancel();
super.dispose();
}
List<EnteFile> get _displayedFiles =>
isSearchActive ? _filteredFiles : _files;
@@ -98,40 +73,14 @@ class _CollectionPageState extends UploaderPageState<CollectionPage>
void initState() {
super.initState();
_initializeData(widget.collection);
_collectionUpdateSubscription =
Bus.instance.on<CollectionsUpdatedEvent>().listen((event) async {
if (!mounted) return;
try {
final collections = await CollectionService.instance.getCollections();
final matchingCollection = collections.where(
(c) => c.id == widget.collection.id,
);
if (matchingCollection.isNotEmpty) {
await _initializeData(matchingCollection.first);
} else {
_logger.warning(
'Collection ${widget.collection.id} no longer exists, navigating back',
);
if (mounted) {
Navigator.of(context).pop();
}
}
} catch (e) {
_logger.severe('Error updating collection: $e');
}
Bus.instance.on<CollectionsUpdatedEvent>().listen((event) async {
final collection = (await CollectionService.instance.getCollections())
.where(
(c) => c.id == widget.collection.id,
)
.first;
await _initializeData(collection);
});
collectionViewType = getCollectionViewType(
_collection,
Configuration.instance.getUserID()!,
);
showFAB = collectionViewType == CollectionViewType.ownedCollection ||
collectionViewType == CollectionViewType.hiddenOwnedCollection ||
collectionViewType == CollectionViewType.quickLink;
}
Future<void> _initializeData(Collection collection) async {
@@ -163,48 +112,6 @@ class _CollectionPageState extends UploaderPageState<CollectionPage>
);
}
Future<void> _shareCollection() async {
final collection = widget.collection;
try {
if ((collectionViewType != CollectionViewType.ownedCollection &&
collectionViewType != CollectionViewType.sharedCollection &&
collectionViewType != CollectionViewType.hiddenOwnedCollection &&
collectionViewType != CollectionViewType.favorite &&
!isQuickLink)) {
throw Exception(
"Cannot share collection of type $collectionViewType",
);
}
if (Configuration.instance.getUserID() == collection.owner.id) {
unawaited(
routeToPage(
context,
(isQuickLink && (collection.hasLink))
? ManageSharedLinkWidget(collection: collection)
: ShareCollectionPage(collection: collection),
),
);
} else {
unawaited(
routeToPage(
context,
AlbumParticipantsPage(collection),
),
);
}
} catch (e, s) {
_logger.severe(e, s);
await showGenericErrorDialog(context: context, error: e);
}
}
Future<void> _leaveCollection() async {
await CollectionActions.leaveCollection(
context,
_collection,
);
}
@override
Widget build(BuildContext context) {
return KeyboardListener(
@@ -232,14 +139,6 @@ class _CollectionPageState extends UploaderPageState<CollectionPage>
actions: [
buildSearchAction(),
...buildSearchActions(),
IconButton(
icon: Icon(
Icons.adaptive.share,
),
onPressed: () async {
await _shareCollection();
},
),
_buildMenuButton(),
],
);
@@ -256,53 +155,33 @@ class _CollectionPageState extends UploaderPageState<CollectionPage>
case 'delete':
_deleteCollection();
break;
case 'leave_collection':
_leaveCollection();
break;
}
},
itemBuilder: (BuildContext context) {
return [
if (collectionViewType == CollectionViewType.ownedCollection ||
collectionViewType == CollectionViewType.hiddenOwnedCollection ||
collectionViewType == CollectionViewType.quickLink)
PopupMenuItem<String>(
value: 'edit',
child: Row(
children: [
const Icon(Icons.edit),
const SizedBox(width: 12),
Text(context.l10n.edit),
],
),
PopupMenuItem<String>(
value: 'edit',
child: Row(
children: [
const Icon(Icons.edit),
const SizedBox(width: 12),
Text(context.l10n.edit),
],
),
if (collectionViewType == CollectionViewType.ownedCollection ||
collectionViewType == CollectionViewType.hiddenOwnedCollection ||
collectionViewType == CollectionViewType.quickLink)
PopupMenuItem<String>(
value: 'delete',
child: Row(
children: [
const Icon(Icons.delete, color: Colors.red),
const SizedBox(width: 12),
Text(
context.l10n.delete,
style: const TextStyle(color: Colors.red),
),
],
),
),
if (collectionViewType == CollectionViewType.sharedCollection)
PopupMenuItem<String>(
value: 'leave_collection',
child: Row(
children: [
const Icon(Icons.logout),
const SizedBox(width: 12),
Text(context.l10n.leaveCollection),
],
),
),
PopupMenuItem<String>(
value: 'delete',
child: Row(
children: [
const Icon(Icons.delete, color: Colors.red),
const SizedBox(width: 12),
Text(
context.l10n.delete,
style: const TextStyle(color: Colors.red),
),
],
),
),
];
},
);
@@ -387,12 +266,10 @@ class _CollectionPageState extends UploaderPageState<CollectionPage>
}
Widget _buildFAB() {
return showFAB
? FloatingActionButton(
onPressed: addFile,
tooltip: context.l10n.addFiles,
child: const Icon(Icons.add),
)
: const SizedBox.shrink();
return FloatingActionButton(
onPressed: addFile,
tooltip: context.l10n.addFiles,
child: const Icon(Icons.add),
);
}
}

View File

@@ -1,10 +1,10 @@
import 'dart:async';
import 'dart:io';
import 'dart:math';
import "package:ente_accounts/services/user_service.dart";
import 'package:ente_events/event_bus.dart';
import 'package:ente_ui/components/buttons/gradient_button.dart';
import "package:ente_ui/components/buttons/icon_button_widget.dart";
import 'package:ente_ui/theme/ente_theme.dart';
import 'package:ente_ui/utils/dialog_util.dart';
import 'package:ente_utils/email_util.dart';
@@ -15,8 +15,6 @@ import 'package:locker/l10n/l10n.dart';
import 'package:locker/services/collections/collections_service.dart';
import 'package:locker/services/collections/models/collection.dart';
import 'package:locker/services/files/sync/models/file.dart';
import "package:locker/ui/collections/collection_flex_grid_view.dart";
import "package:locker/ui/collections/section_title.dart";
import 'package:locker/ui/components/recents_section_widget.dart';
import 'package:locker/ui/components/search_result_view.dart';
import 'package:locker/ui/mixins/search_mixin.dart';
@@ -26,6 +24,7 @@ import "package:locker/ui/pages/settings_page.dart";
import 'package:locker/ui/pages/uploader_page.dart';
import 'package:locker/utils/collection_actions.dart';
import 'package:locker/utils/collection_sort_util.dart';
import "package:locker/utils/snack_bar_utils.dart";
import 'package:logging/logging.dart';
class HomePage extends UploaderPage {
@@ -51,13 +50,7 @@ class _HomePageState extends UploaderPageState<HomePage>
List<Collection> _filteredCollections = [];
List<EnteFile> _recentFiles = [];
List<EnteFile> _filteredFiles = [];
List<Collection> outgoingCollections = [];
List<Collection> incomingCollections = [];
List<Collection> quickLinks = [];
Map<int, int> _outgoingCollectionFileCounts = {};
Map<int, int> _incomingCollectionFileCounts = {};
Map<int, int> _homeCollectionFileCounts = {};
Map<int, int> _collectionFileCounts = {};
String? _error;
final _logger = Logger('HomePage');
StreamSubscription? _mediaStreamSubscription;
@@ -95,17 +88,7 @@ class _HomePageState extends UploaderPageState<HomePage>
}
List<Collection> get _displayedCollections {
final List<Collection> collections;
if (isSearchActive) {
collections = _filteredCollections;
} else {
final excludeIds = {
...incomingCollections.map((c) => c.id),
...quickLinks.map((c) => c.id),
};
collections =
_collections.where((c) => !excludeIds.contains(c.id)).toList();
}
final collections = isSearchActive ? _filteredCollections : _collections;
return _filterOutUncategorized(collections);
}
@@ -285,16 +268,10 @@ class _HomePageState extends UploaderPageState<HomePage>
final sortedCollections =
CollectionSortUtil.getSortedCollections(collections);
final sharedCollections =
await CollectionService.instance.getSharedCollections();
setState(() {
_collections = sortedCollections;
_filteredCollections = _filterOutUncategorized(sortedCollections);
_filteredFiles = _recentFiles;
incomingCollections = sharedCollections.incoming;
outgoingCollections = sharedCollections.outgoing;
quickLinks = sharedCollections.quickLinks;
_isLoading = false;
});
@@ -514,26 +491,10 @@ class _HomePageState extends UploaderPageState<HomePage>
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
..._buildCollectionSection(
title: context.l10n.collections,
collections: _displayedCollections,
viewType: UISectionType.homeCollections,
fileCounts: _homeCollectionFileCounts,
),
if (outgoingCollections.isNotEmpty)
..._buildCollectionSection(
title: context.l10n.sharedByYou,
collections: outgoingCollections,
viewType: UISectionType.outgoingCollections,
fileCounts: _outgoingCollectionFileCounts,
),
if (incomingCollections.isNotEmpty)
..._buildCollectionSection(
title: context.l10n.sharedWithYou,
collections: incomingCollections,
viewType: UISectionType.incomingCollections,
fileCounts: _incomingCollectionFileCounts,
),
_buildCollectionsHeader(),
const SizedBox(height: 24),
_buildCollectionsGrid(),
const SizedBox(height: 24),
_buildRecentsSection(),
],
),
@@ -596,6 +557,105 @@ class _HomePageState extends UploaderPageState<HomePage>
}
}
Widget _buildCollectionsHeader() {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
SnackBarUtils.showWarningSnackBar(context, "Hello");
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const AllCollectionsPage(),
),
);
},
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
context.l10n.collections,
style: getEnteTextTheme(context).h3Bold,
),
const Icon(
Icons.chevron_right,
color: Colors.grey,
),
],
),
);
}
Widget _buildCollectionsGrid() {
return MediaQuery.removePadding(
context: context,
removeBottom: true,
removeTop: true,
child: GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
childAspectRatio: 2.2,
),
itemCount: min(_displayedCollections.length, 4),
itemBuilder: (context, index) {
final collection = _displayedCollections[index];
final collectionName = collection.name ?? 'Unnamed Collection';
return GestureDetector(
onTap: () => _navigateToCollection(collection),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: getEnteColorScheme(context).fillFaint,
),
padding: const EdgeInsets.all(12),
child: Stack(
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
collectionName,
style: getEnteTextTheme(context).body.copyWith(
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.left,
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
const SizedBox(height: 4),
Text(
context.l10n
.items(_collectionFileCounts[collection.id] ?? 0),
style: getEnteTextTheme(context).small.copyWith(
color: Colors.grey[600],
),
textAlign: TextAlign.left,
),
],
),
if (collection.type == CollectionType.favorites)
Positioned(
top: 0,
right: 0,
child: Icon(
Icons.star,
color: getEnteColorScheme(context).primary500,
size: 18,
),
),
],
),
),
);
},
),
);
}
Widget _buildMultiOptionFab() {
return ValueListenableBuilder<bool>(
valueListenable: _isFabOpen,
@@ -730,79 +790,22 @@ class _HomePageState extends UploaderPageState<HomePage>
}
Future<void> _loadCollectionFileCounts() async {
final mainCounts = <int, int>{};
final outgoingCounts = <int, int>{};
final incomingCounts = <int, int>{};
final counts = <int, int>{};
await Future.wait([
..._displayedCollections.take(4).map((collection) async {
try {
final files =
await CollectionService.instance.getFilesInCollection(collection);
mainCounts[collection.id] = files.length;
} catch (e) {
mainCounts[collection.id] = 0;
}
}),
...outgoingCollections.take(4).map((collection) async {
try {
final files =
await CollectionService.instance.getFilesInCollection(collection);
outgoingCounts[collection.id] = files.length;
} catch (e) {
outgoingCounts[collection.id] = 0;
}
}),
...incomingCollections.take(4).map((collection) async {
try {
final files =
await CollectionService.instance.getFilesInCollection(collection);
incomingCounts[collection.id] = files.length;
} catch (e) {
incomingCounts[collection.id] = 0;
}
}),
]);
for (final collection in _displayedCollections.take(4)) {
try {
final files =
await CollectionService.instance.getFilesInCollection(collection);
counts[collection.id] = files.length;
} catch (e) {
counts[collection.id] = 0;
}
}
if (mounted) {
setState(() {
_homeCollectionFileCounts = mainCounts;
_outgoingCollectionFileCounts = outgoingCounts;
_incomingCollectionFileCounts = incomingCounts;
_collectionFileCounts = counts;
});
}
}
List<Widget> _buildCollectionSection({
required String title,
required List<Collection> collections,
required UISectionType viewType,
required Map<int, int> fileCounts,
}) {
return [
SectionOptions(
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => AllCollectionsPage(
viewType: viewType,
),
),
);
},
SectionTitle(title: title),
trailingWidget: IconButtonWidget(
icon: Icons.chevron_right,
iconButtonType: IconButtonType.secondary,
iconColor: getEnteColorScheme(context).blurStrokePressed,
),
),
const SizedBox(height: 24),
CollectionFlexGridViewWidget(
collections: collections,
collectionFileCounts: fileCounts,
),
const SizedBox(height: 24),
];
}
}

View File

@@ -4,7 +4,6 @@ import "package:ente_accounts/pages/password_entry_page.dart";
import "package:ente_accounts/pages/recovery_key_page.dart";
import "package:ente_accounts/services/user_service.dart";
import "package:ente_crypto_dart/ente_crypto_dart.dart";
import "package:ente_legacy/pages/emergency_page.dart";
import "package:ente_lock_screen/local_authentication_service.dart";
import "package:ente_ui/components/captioned_text_widget.dart";
import "package:ente_ui/components/menu_item_widget.dart";
@@ -12,7 +11,6 @@ import "package:ente_ui/theme/ente_theme.dart";
import "package:ente_ui/utils/dialog_util.dart";
import "package:ente_utils/navigation_util.dart";
import "package:ente_utils/platform_util.dart";
import "package:flutter/foundation.dart";
import "package:flutter/material.dart";
import "package:locker/l10n/l10n.dart";
import "package:locker/services/configuration.dart";
@@ -137,35 +135,6 @@ class AccountSectionWidget extends StatelessWidget {
},
),
sectionOptionSpacing,
MenuItemWidget(
captionedTextWidget: CaptionedTextWidget(
title: context.l10n.legacy,
),
pressedColor: getEnteColorScheme(context).fillFaint,
trailingIcon: Icons.chevron_right_outlined,
trailingIconIsMuted: true,
showOnlyLoadingState: true,
onTap: () async {
final hasAuthenticated = kDebugMode ||
await LocalAuthenticationService.instance
.requestLocalAuthentication(
context,
"Authenticate to manage legacy contacts",
);
if (hasAuthenticated) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (BuildContext context) {
return EmergencyPage(
config: Configuration.instance,
);
},
),
).ignore();
}
},
),
sectionOptionSpacing,
MenuItemWidget(
captionedTextWidget: CaptionedTextWidget(
title: context.l10n.logout,

View File

@@ -13,7 +13,7 @@ import "package:ente_lock_screen/ui/lock_screen_options.dart";
import "package:ente_ui/components/captioned_text_widget.dart";
import "package:ente_ui/components/menu_item_widget.dart";
import "package:ente_ui/components/toggle_switch_widget.dart";
import "package:ente_ui/theme/ente_theme.dart";
import "package:ente_ui/theme/ente_theme.dart";
import "package:ente_ui/utils/dialog_util.dart";
import "package:ente_ui/utils/toast_util.dart";
import "package:ente_utils/navigation_util.dart";
@@ -122,7 +122,7 @@ class _SecuritySectionWidgetState extends State<SecuritySectionWidget> {
trailingIcon: Icons.chevron_right_outlined,
trailingIconIsMuted: true,
onTap: () async {
if (await LockScreenSettings.instance.isDeviceSupported()) {
if (await LockScreenSettings.instance.shouldShowLockScreen()) {
final bool result = await requestAuthentication(
context,
context.l10n.authToChangeLockscreenSetting,
@@ -137,17 +137,19 @@ class _SecuritySectionWidgetState extends State<SecuritySectionWidget> {
);
}
} else {
await showErrorDialog(
context,
context.l10n.noSystemLockFound,
context.l10n.toEnableAppLockPleaseSetupDevicePasscodeOrScreen,
await Navigator.of(context).push(
MaterialPageRoute(
builder: (BuildContext context) {
return const LockScreenOptions();
},
),
);
}
},
),
sectionOptionSpacing,
]);
return Column(
children: children,
);

View File

@@ -1,471 +0,0 @@
import 'package:email_validator/email_validator.dart';
import "package:ente_sharing/models/user.dart";
import "package:ente_sharing/user_avator_widget.dart";
import "package:ente_sharing/verify_identity_dialog.dart";
import "package:ente_ui/components/buttons/button_widget.dart";
import "package:ente_ui/components/buttons/models/button_type.dart";
import "package:ente_ui/components/captioned_text_widget.dart";
import "package:ente_ui/components/divider_widget.dart";
import "package:ente_ui/components/menu_item_widget.dart";
import "package:ente_ui/components/menu_section_description_widget.dart";
import "package:ente_ui/components/menu_section_title.dart";
import "package:ente_ui/components/separators.dart";
import "package:ente_ui/theme/ente_theme.dart";
import "package:ente_ui/utils/toast_util.dart";
import 'package:flutter/material.dart';
import "package:locker/extensions/user_extension.dart";
import "package:locker/l10n/l10n.dart";
import "package:locker/services/collections/collections_service.dart";
import "package:locker/services/collections/models/collection.dart";
import "package:locker/services/configuration.dart";
import "package:locker/utils/collection_actions.dart";
enum ActionTypesToShow {
addViewer,
addCollaborator,
}
class AddParticipantPage extends StatefulWidget {
/// Cannot be empty
final List<ActionTypesToShow> actionTypesToShow;
final List<Collection> collections;
AddParticipantPage(
this.collections,
this.actionTypesToShow, {
super.key,
}) : assert(
actionTypesToShow.isNotEmpty,
'actionTypesToShow cannot be empty',
);
@override
State<StatefulWidget> createState() => _AddParticipantPage();
}
class _AddParticipantPage extends State<AddParticipantPage> {
final _selectedEmails = <String>{};
String _newEmail = '';
bool _emailIsValid = false;
bool isKeypadOpen = false;
late List<User> _suggestedUsers;
// Focus nodes are necessary
final textFieldFocusNode = FocusNode();
final _textController = TextEditingController();
late CollectionActions collectionActions;
@override
void initState() {
super.initState();
_suggestedUsers = _getSuggestedUser();
collectionActions = CollectionActions();
}
@override
void dispose() {
_textController.dispose();
textFieldFocusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final filterSuggestedUsers = _suggestedUsers
.where(
(element) =>
(element.displayName ?? element.email).toLowerCase().contains(
_textController.text.trim().toLowerCase(),
),
)
.toList();
isKeypadOpen = MediaQuery.viewInsetsOf(context).bottom > 100;
final enteTextTheme = getEnteTextTheme(context);
final enteColorScheme = getEnteColorScheme(context);
return Scaffold(
resizeToAvoidBottomInset: isKeypadOpen,
appBar: AppBar(
title: Text(
_getTitle(),
),
),
body: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Text(
context.l10n.addANewEmail,
style: enteTextTheme.small
.copyWith(color: enteColorScheme.textMuted),
),
),
const SizedBox(height: 4),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: _enterEmailField(),
),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Column(
children: [
filterSuggestedUsers.isNotEmpty
? MenuSectionTitle(
title: context.l10n.orPickAnExistingOne,
)
: const SizedBox.shrink(),
Expanded(
child: ListView.builder(
physics: const BouncingScrollPhysics(),
itemBuilder: (context, index) {
if (index >= filterSuggestedUsers.length) {
return Padding(
padding: const EdgeInsets.symmetric(
vertical: 8.0,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
filterSuggestedUsers.isNotEmpty
? MenuSectionDescriptionWidget(
content: context.l10n
.longPressAnEmailToVerifyEndToEndEncryption,
)
: const SizedBox.shrink(),
widget.actionTypesToShow.contains(
ActionTypesToShow.addCollaborator,
)
? MenuSectionDescriptionWidget(
content: context.l10n
.collaboratorsCanAddFilesToTheSharedCollection,
)
: const SizedBox.shrink(),
],
),
);
}
final currentUser = filterSuggestedUsers[index];
return Column(
children: [
MenuItemWidget(
key: ValueKey(
currentUser.displayName ?? currentUser.email,
),
captionedTextWidget: CaptionedTextWidget(
title: currentUser.displayName ??
currentUser.email,
),
leadingIconSize: 24.0,
leadingIconWidget: UserAvatarWidget(
currentUser,
type: AvatarType.mini,
config: Configuration.instance,
),
menuItemColor:
getEnteColorScheme(context).fillFaint,
pressedColor:
getEnteColorScheme(context).fillFaint,
trailingIcon:
(_selectedEmails.contains(currentUser.email))
? Icons.check
: null,
onTap: () async {
textFieldFocusNode.unfocus();
if (_selectedEmails
.contains(currentUser.email)) {
_selectedEmails.remove(currentUser.email);
} else {
_selectedEmails.add(currentUser.email);
}
setState(() => {});
// showShortToast(context, "yet to implement");
},
onLongPress: () {
showDialog(
useRootNavigator: false,
context: context,
builder: (BuildContext context) {
return VerifyIdentityDialog(
self: false,
email: currentUser.email,
config: Configuration.instance,
);
},
);
},
isTopBorderRadiusRemoved: index > 0,
isBottomBorderRadiusRemoved:
index < (filterSuggestedUsers.length - 1),
),
(index == (filterSuggestedUsers.length - 1))
? const SizedBox.shrink()
: DividerWidget(
dividerType: DividerType.menu,
bgColor:
getEnteColorScheme(context).fillFaint,
),
],
);
},
itemCount: filterSuggestedUsers.length + 1,
),
),
],
),
),
),
SafeArea(
child: Padding(
padding: const EdgeInsets.only(
top: 8,
bottom: 8,
left: 16,
right: 16,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const SizedBox(height: 8),
..._actionButtons(),
const SizedBox(height: 12),
],
),
),
),
],
),
);
}
List<Widget> _actionButtons() {
final widgets = <Widget>[];
if (widget.actionTypesToShow.contains(ActionTypesToShow.addViewer)) {
widgets.add(
ButtonWidget(
buttonType: ButtonType.primary,
buttonSize: ButtonSize.large,
labelText: context.l10n.addViewers(_selectedEmails.length),
isDisabled: _selectedEmails.isEmpty,
onTap: () async {
final results = <bool>[];
final collections = widget.collections;
for (String email in _selectedEmails) {
bool result = false;
for (Collection collection in collections) {
result = await collectionActions.addEmailToCollection(
context,
collection,
email,
CollectionParticipantRole.viewer,
);
}
results.add(result);
}
final noOfSuccessfullAdds = results.where((e) => e).length;
showToast(
context,
context.l10n.viewersSuccessfullyAdded(noOfSuccessfullAdds),
);
if (!results.any((e) => e == false) && mounted) {
Navigator.of(context).pop(true);
}
},
),
);
}
if (widget.actionTypesToShow.contains(
ActionTypesToShow.addCollaborator,
)) {
widgets.add(
ButtonWidget(
buttonType:
widget.actionTypesToShow.contains(ActionTypesToShow.addViewer)
? ButtonType.neutral
: ButtonType.primary,
buttonSize: ButtonSize.large,
labelText: context.l10n.addCollaborators(_selectedEmails.length),
isDisabled: _selectedEmails.isEmpty,
onTap: () async {
// TODO: This is not currently designed for best UX for action on
// multiple collections and emails, especially if some operations
// fail. Can be improved by using a different 'addEmailToCollection'
// that accepts list of emails and list of collections.
final results = <bool>[];
final collections = widget.collections;
for (String email in _selectedEmails) {
bool result = false;
for (Collection collection in collections) {
result = await collectionActions.addEmailToCollection(
context,
collection,
email,
CollectionParticipantRole.collaborator,
);
}
results.add(result);
}
final noOfSuccessfullAdds = results.where((e) => e).length;
showToast(
context,
context.l10n.collaboratorsSuccessfullyAdded(noOfSuccessfullAdds),
);
if (!results.any((e) => e == false) && mounted) {
Navigator.of(context).pop(true);
}
},
),
);
}
final widgetsWithSpaceBetween = addSeparators(
widgets,
const SizedBox(
height: 8,
),
);
return widgetsWithSpaceBetween;
}
void clearFocus() {
_textController.clear();
_newEmail = _textController.text;
_emailIsValid = false;
textFieldFocusNode.unfocus();
setState(() => {});
}
Widget _enterEmailField() {
return Row(
children: [
Expanded(
child: TextFormField(
controller: _textController,
focusNode: textFieldFocusNode,
style: getEnteTextTheme(context).body,
autofillHints: const [AutofillHints.email],
decoration: InputDecoration(
focusedBorder: OutlineInputBorder(
borderRadius: const BorderRadius.all(Radius.circular(4.0)),
borderSide:
BorderSide(color: getEnteColorScheme(context).strokeMuted),
),
fillColor: getEnteColorScheme(context).fillFaint,
filled: true,
hintText: context.l10n.enterEmail,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,
),
border: UnderlineInputBorder(
borderSide: BorderSide.none,
borderRadius: BorderRadius.circular(4),
),
prefixIcon: Icon(
Icons.email_outlined,
color: getEnteColorScheme(context).strokeMuted,
),
suffixIcon: _newEmail == ''
? null
: IconButton(
onPressed: clearFocus,
icon: Icon(
Icons.cancel,
color: getEnteColorScheme(context).strokeMuted,
),
),
),
onChanged: (value) {
_newEmail = value.trim();
_emailIsValid = EmailValidator.validate(_newEmail);
setState(() {});
},
autocorrect: false,
keyboardType: TextInputType.emailAddress,
//initialValue: _email,
textInputAction: TextInputAction.next,
),
),
const SizedBox(width: 8),
ButtonWidget(
buttonType: ButtonType.secondary,
buttonSize: ButtonSize.small,
labelText: context.l10n.add,
isDisabled: !_emailIsValid,
onTap: () async {
if (_emailIsValid) {
final result = await collectionActions.doesEmailHaveAccount(
context,
_newEmail,
);
if (result && mounted) {
setState(() {
for (var suggestedUser in _suggestedUsers) {
if (suggestedUser.email == _newEmail) {
_selectedEmails.add(suggestedUser.email);
clearFocus();
return;
}
}
_suggestedUsers.insert(0, User(email: _newEmail));
_selectedEmails.add(_newEmail);
clearFocus();
});
}
}
},
),
],
);
}
List<User> _getSuggestedUser() {
final Set<String> existingEmails = {};
final collections = widget.collections;
if (collections.isEmpty) {
return [];
}
for (final Collection collection in collections) {
for (final User u in collection.sharees) {
if (u.id != null && u.email.isNotEmpty) {
existingEmails.add(u.email);
}
}
}
final List<User> suggestedUsers =
CollectionService.instance.getRelevantContacts();
if (_textController.text.trim().isNotEmpty) {
suggestedUsers.removeWhere(
(element) => !(element.displayName ?? element.email)
.toLowerCase()
.contains(_textController.text.trim().toLowerCase()),
);
}
suggestedUsers.sort((a, b) => a.email.compareTo(b.email));
return suggestedUsers;
}
String _getTitle() {
if (widget.actionTypesToShow.length > 1) {
return context.l10n.addParticipants;
} else if (widget.actionTypesToShow.first == ActionTypesToShow.addViewer) {
return context.l10n.addViewer;
} else {
return context.l10n.addCollaborator;
}
}
}

View File

@@ -1,310 +0,0 @@
import "package:ente_sharing/models/user.dart";
import "package:ente_sharing/user_avator_widget.dart";
import "package:ente_ui/components/captioned_text_widget.dart";
import "package:ente_ui/components/divider_widget.dart";
import "package:ente_ui/components/menu_item_widget.dart";
import "package:ente_ui/components/menu_section_title.dart";
import "package:ente_ui/components/title_bar_title_widget.dart";
import "package:ente_ui/components/title_bar_widget.dart";
import "package:ente_ui/theme/ente_theme.dart";
import "package:ente_utils/ente_utils.dart";
import 'package:flutter/material.dart';
import "package:locker/extensions/user_extension.dart";
import "package:locker/l10n/l10n.dart";
import "package:locker/services/collections/models/collection.dart";
import "package:locker/services/configuration.dart";
import "package:locker/ui/sharing/add_participant_page.dart";
import "package:locker/ui/sharing/manage_album_participant.dart";
class AlbumParticipantsPage extends StatefulWidget {
final Collection collection;
const AlbumParticipantsPage(
this.collection, {
super.key,
});
@override
State<AlbumParticipantsPage> createState() => _AlbumParticipantsPageState();
}
class _AlbumParticipantsPageState extends State<AlbumParticipantsPage> {
late int currentUserID;
@override
void initState() {
currentUserID = Configuration.instance.getUserID()!;
super.initState();
}
Future<void> _navigateToManageUser(User user) async {
if (user.id == currentUserID) {
return;
}
await routeToPage(
context,
ManageIndividualParticipant(collection: widget.collection, user: user),
);
if (mounted) {
setState(() => {});
}
}
Future<void> _navigateToAddUser(bool addingViewer) async {
await routeToPage(
context,
AddParticipantPage(
[widget.collection],
addingViewer
? [ActionTypesToShow.addViewer]
: [ActionTypesToShow.addCollaborator],
),
);
if (mounted) {
setState(() => {});
}
}
@override
Widget build(BuildContext context) {
final isOwner =
widget.collection.owner.id == Configuration.instance.getUserID();
final colorScheme = getEnteColorScheme(context);
final currentUserID = Configuration.instance.getUserID()!;
final int participants = 1 + widget.collection.getSharees().length;
final User owner = widget.collection.owner;
if (owner.id == currentUserID && owner.email == "") {
owner.email = Configuration.instance.getEmail()!;
}
final splitResult =
widget.collection.getSharees().splitMatch((x) => x.isViewer);
final List<User> viewers = splitResult.matched;
viewers.sort((a, b) => a.email.compareTo(b.email));
final List<User> collaborators = splitResult.unmatched;
collaborators.sort((a, b) => a.email.compareTo(b.email));
return Scaffold(
body: CustomScrollView(
primary: false,
slivers: <Widget>[
TitleBarWidget(
flexibleSpaceTitle: TitleBarTitleWidget(
title: widget.collection.name,
),
flexibleSpaceCaption:
context.l10n.albumParticipantsCount(participants),
),
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
return Padding(
padding: const EdgeInsets.only(top: 20, left: 16, right: 16),
child: Column(
children: [
Column(
children: [
MenuSectionTitle(
title: context.l10n.albumOwner,
iconData: Icons.admin_panel_settings_outlined,
),
MenuItemWidget(
captionedTextWidget: CaptionedTextWidget(
title: isOwner
? context.l10n.you
: _nameIfAvailableElseEmail(
widget.collection.owner,
),
makeTextBold: isOwner,
),
leadingIconWidget: UserAvatarWidget(
owner,
currentUserID: currentUserID,
config: Configuration.instance,
),
leadingIconSize: 24,
menuItemColor: colorScheme.fillFaint,
singleBorderRadius: 8,
isGestureDetectorDisabled: true,
),
],
),
],
),
);
},
childCount: 1,
),
),
SliverPadding(
padding: const EdgeInsets.only(top: 20, left: 16, right: 16),
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
if (index == 0 && (isOwner || collaborators.isNotEmpty)) {
return MenuSectionTitle(
title: context.l10n.collaborator,
iconData: Icons.edit_outlined,
);
} else if (index > 0 && index <= collaborators.length) {
final listIndex = index - 1;
final currentUser = collaborators[listIndex];
final isSameAsLoggedInUser =
currentUserID == currentUser.id;
final isLastItem =
!isOwner && index == collaborators.length;
return Column(
children: [
MenuItemWidget(
captionedTextWidget: CaptionedTextWidget(
title: isSameAsLoggedInUser
? context.l10n.you
: _nameIfAvailableElseEmail(currentUser),
makeTextBold: isSameAsLoggedInUser,
),
leadingIconSize: 24.0,
leadingIconWidget: UserAvatarWidget(
currentUser,
type: AvatarType.mini,
currentUserID: currentUserID,
config: Configuration.instance,
),
menuItemColor: getEnteColorScheme(context).fillFaint,
trailingIcon: isOwner ? Icons.chevron_right : null,
trailingIconIsMuted: true,
onTap: isOwner
? () async {
if (isOwner) {
// ignore: unawaited_futures
_navigateToManageUser(currentUser);
}
}
: null,
isTopBorderRadiusRemoved: listIndex > 0,
isBottomBorderRadiusRemoved: !isLastItem,
singleBorderRadius: 8,
),
isLastItem
? const SizedBox.shrink()
: DividerWidget(
dividerType: DividerType.menu,
bgColor: getEnteColorScheme(context).fillFaint,
),
],
);
} else if (index == (1 + collaborators.length) && isOwner) {
return MenuItemWidget(
captionedTextWidget: CaptionedTextWidget(
title: collaborators.isNotEmpty
? context.l10n.addMore
: context.l10n.addCollaborator,
makeTextBold: true,
),
leadingIcon: Icons.add_outlined,
menuItemColor: getEnteColorScheme(context).fillFaint,
onTap: () async {
// ignore: unawaited_futures
_navigateToAddUser(false);
},
isTopBorderRadiusRemoved: collaborators.isNotEmpty,
singleBorderRadius: 8,
);
}
return const SizedBox.shrink();
},
childCount: 1 + collaborators.length + 1,
),
),
),
SliverPadding(
padding: const EdgeInsets.only(top: 24, left: 16, right: 16),
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
if (index == 0 && (isOwner || viewers.isNotEmpty)) {
return MenuSectionTitle(
title: context.l10n.viewer,
iconData: Icons.photo_outlined,
);
} else if (index > 0 && index <= viewers.length) {
final listIndex = index - 1;
final currentUser = viewers[listIndex];
final isSameAsLoggedInUser =
currentUserID == currentUser.id;
final isLastItem = !isOwner && index == viewers.length;
return Column(
children: [
MenuItemWidget(
captionedTextWidget: CaptionedTextWidget(
title: isSameAsLoggedInUser
? context.l10n.you
: _nameIfAvailableElseEmail(currentUser),
makeTextBold: isSameAsLoggedInUser,
),
leadingIconSize: 24.0,
leadingIconWidget: UserAvatarWidget(
currentUser,
type: AvatarType.mini,
currentUserID: currentUserID,
config: Configuration.instance,
),
menuItemColor: getEnteColorScheme(context).fillFaint,
trailingIcon: isOwner ? Icons.chevron_right : null,
trailingIconIsMuted: true,
onTap: isOwner
? () async {
if (isOwner) {
// ignore: unawaited_futures
_navigateToManageUser(currentUser);
}
}
: null,
isTopBorderRadiusRemoved: listIndex > 0,
isBottomBorderRadiusRemoved: !isLastItem,
singleBorderRadius: 8,
),
isLastItem
? const SizedBox.shrink()
: DividerWidget(
dividerType: DividerType.menu,
bgColor: getEnteColorScheme(context).fillFaint,
),
],
);
} else if (index == (1 + viewers.length) && isOwner) {
return MenuItemWidget(
captionedTextWidget: CaptionedTextWidget(
title: viewers.isNotEmpty
? context.l10n.addMore
: context.l10n.addViewer,
makeTextBold: true,
),
leadingIcon: Icons.add_outlined,
menuItemColor: getEnteColorScheme(context).fillFaint,
onTap: () async {
// ignore: unawaited_futures
_navigateToAddUser(true);
},
isTopBorderRadiusRemoved: viewers.isNotEmpty,
singleBorderRadius: 8,
);
}
return const SizedBox.shrink();
},
childCount: 1 + viewers.length + 1,
),
),
),
const SliverToBoxAdapter(child: SizedBox(height: 72)),
],
),
);
}
String _nameIfAvailableElseEmail(User user) {
final name = user.displayName;
if (name != null && name.isNotEmpty) {
return name;
}
return user.email;
}
}

View File

@@ -1,104 +0,0 @@
import "dart:math";
import "package:ente_sharing/models/user.dart";
import "package:ente_sharing/user_avator_widget.dart";
import "package:flutter/material.dart";
import "package:locker/services/configuration.dart";
import "package:locker/ui/sharing/more_count_badge.dart";
class AlbumSharesIcons extends StatelessWidget {
final List<User> sharees;
final int limitCountTo;
final AvatarType type;
final bool removeBorder;
final EdgeInsets padding;
final Widget? trailingWidget;
final Alignment stackAlignment;
const AlbumSharesIcons({
super.key,
required this.sharees,
this.type = AvatarType.tiny,
this.limitCountTo = 2,
this.removeBorder = true,
this.trailingWidget,
this.padding = const EdgeInsets.only(left: 10.0, top: 10, bottom: 10),
this.stackAlignment = Alignment.topLeft,
});
@override
Widget build(BuildContext context) {
final displayCount = min(sharees.length, limitCountTo);
final hasMore = sharees.length > limitCountTo;
final double overlapPadding = getOverlapPadding(type);
final widgets = List<Widget>.generate(
displayCount,
(index) => Positioned(
left: overlapPadding * index,
child: UserAvatarWidget(
sharees[index],
thumbnailView: removeBorder,
type: type,
config: Configuration.instance,
),
),
);
if (hasMore) {
widgets.add(
Positioned(
left: (overlapPadding * displayCount),
child: MoreCountWidget(
sharees.length - displayCount,
type: moreCountTypeFromAvatarType(type),
thumbnailView: removeBorder,
),
),
);
}
if (trailingWidget != null) {
widgets.add(
Positioned(
left: (overlapPadding * (displayCount + (hasMore ? 1 : 0))) +
(displayCount > 0 ? 12 : 0),
child: trailingWidget!,
),
);
}
return Padding(
padding: padding,
child: Stack(
alignment: stackAlignment,
clipBehavior: Clip.none,
children: widgets,
),
);
}
}
double getOverlapPadding(AvatarType type) {
switch (type) {
case AvatarType.extra:
return 14.0;
case AvatarType.tiny:
return 14.0;
case AvatarType.mini:
return 20.0;
case AvatarType.small:
return 28.0;
}
}
MoreCountType moreCountTypeFromAvatarType(AvatarType type) {
switch (type) {
case AvatarType.extra:
return MoreCountType.extra;
case AvatarType.tiny:
return MoreCountType.tiny;
case AvatarType.mini:
return MoreCountType.mini;
case AvatarType.small:
return MoreCountType.small;
}
}

View File

@@ -1,188 +0,0 @@
import "package:ente_sharing/models/user.dart";
import "package:ente_ui/components/buttons/button_widget.dart";
import "package:ente_ui/components/captioned_text_widget.dart";
import "package:ente_ui/components/divider_widget.dart";
import "package:ente_ui/components/menu_item_widget.dart";
import "package:ente_ui/components/menu_section_description_widget.dart";
import "package:ente_ui/components/menu_section_title.dart";
import "package:ente_ui/components/title_bar_title_widget.dart";
import "package:ente_ui/theme/colors.dart";
import "package:ente_ui/theme/ente_theme.dart";
import "package:ente_ui/utils/dialog_util.dart";
import 'package:flutter/material.dart';
import "package:locker/extensions/user_extension.dart";
import "package:locker/l10n/l10n.dart";
import "package:locker/services/collections/models/collection.dart";
import "package:locker/utils/collection_actions.dart";
class ManageIndividualParticipant extends StatefulWidget {
final Collection collection;
final User user;
const ManageIndividualParticipant({
super.key,
required this.collection,
required this.user,
});
@override
State<StatefulWidget> createState() => _ManageIndividualParticipantState();
}
class _ManageIndividualParticipantState
extends State<ManageIndividualParticipant> {
late CollectionActions collectionActions;
@override
void initState() {
super.initState();
collectionActions = CollectionActions();
}
@override
Widget build(BuildContext context) {
final colorScheme = getEnteColorScheme(context);
final textTheme = getEnteTextTheme(context);
bool isConvertToViewSuccess = false;
return Scaffold(
appBar: AppBar(),
body: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(
height: 12,
),
TitleBarTitleWidget(
title: context.l10n.manage,
),
Text(
widget.user.email,
textAlign: TextAlign.left,
style:
textTheme.small.copyWith(color: colorScheme.textMuted),
),
],
),
),
const SizedBox(height: 12),
MenuSectionTitle(title: context.l10n.addedAs),
MenuItemWidget(
captionedTextWidget: CaptionedTextWidget(
title: context.l10n.collaborator,
),
leadingIcon: Icons.edit_outlined,
menuItemColor: getEnteColorScheme(context).fillFaint,
trailingIcon: widget.user.isCollaborator ? Icons.check : null,
onTap: widget.user.isCollaborator
? null
: () async {
final result =
await collectionActions.addEmailToCollection(
context,
widget.collection,
widget.user.email,
CollectionParticipantRole.collaborator,
);
if (result && mounted) {
widget.user.role = CollectionParticipantRole
.collaborator
.toStringVal();
setState(() => {});
}
},
isBottomBorderRadiusRemoved: true,
),
DividerWidget(
dividerType: DividerType.menu,
bgColor: getEnteColorScheme(context).fillFaint,
),
MenuItemWidget(
captionedTextWidget: CaptionedTextWidget(
title: context.l10n.viewer,
),
leadingIcon: Icons.photo_outlined,
leadingIconColor: getEnteColorScheme(context).strokeBase,
menuItemColor: getEnteColorScheme(context).fillFaint,
trailingIcon: widget.user.isViewer ? Icons.check : null,
showOnlyLoadingState: true,
onTap: widget.user.isViewer
? null
: () async {
final actionResult = await showChoiceActionSheet(
context,
title: context.l10n.changePermissions,
firstButtonLabel: context.l10n.yesConvertToViewer,
body:
context.l10n.cannotAddMoreFilesAfterBecomingViewer(
widget.user.displayName ?? widget.user.email,
),
isCritical: true,
);
if (actionResult?.action != null) {
if (actionResult!.action == ButtonAction.first) {
try {
isConvertToViewSuccess =
await collectionActions.addEmailToCollection(
context,
widget.collection,
widget.user.email,
CollectionParticipantRole.viewer,
);
} catch (e) {
await showGenericErrorDialog(
context: context,
error: e,
);
}
if (isConvertToViewSuccess && mounted) {
// reset value
isConvertToViewSuccess = false;
widget.user.role =
CollectionParticipantRole.viewer.toStringVal();
setState(() => {});
}
}
}
},
isTopBorderRadiusRemoved: true,
),
MenuSectionDescriptionWidget(
content: context.l10n.collaboratorsCanAddFilesToTheSharedAlbum,
),
const SizedBox(height: 24),
MenuSectionTitle(title: context.l10n.removeParticipant),
MenuItemWidget(
captionedTextWidget: CaptionedTextWidget(
title: context.l10n.remove,
textColor: warning500,
makeTextBold: true,
),
leadingIcon: Icons.not_interested_outlined,
leadingIconColor: warning500,
menuItemColor: getEnteColorScheme(context).fillFaint,
surfaceExecutionStates: false,
onTap: () async {
final result = await collectionActions.removeParticipant(
context,
widget.collection,
widget.user,
);
if ((result) && mounted) {
Navigator.of(context).pop(true);
}
},
),
],
),
),
);
}
}

View File

@@ -1,353 +0,0 @@
import "dart:convert";
import "package:ente_crypto_dart/ente_crypto_dart.dart";
import "package:ente_ui/components/captioned_text_widget.dart";
import "package:ente_ui/components/divider_widget.dart";
import "package:ente_ui/components/menu_item_widget.dart";
import "package:ente_ui/components/menu_section_description_widget.dart";
import "package:ente_ui/components/toggle_switch_widget.dart";
import "package:ente_ui/theme/colors.dart";
import "package:ente_ui/theme/ente_theme.dart";
import "package:ente_ui/utils/dialog_util.dart";
import "package:ente_ui/utils/toast_util.dart";
import "package:ente_utils/navigation_util.dart";
import "package:ente_utils/share_utils.dart";
import "package:flutter/material.dart";
import "package:flutter/services.dart";
import "package:locker/l10n/l10n.dart";
import "package:locker/services/collections/collections_api_client.dart";
import "package:locker/services/collections/collections_service.dart";
import "package:locker/services/collections/models/collection.dart";
import "package:locker/services/collections/models/public_url.dart";
import "package:locker/ui/sharing/pickers/device_limit_picker_page.dart";
import "package:locker/ui/sharing/pickers/link_expiry_picker_page.dart";
import "package:locker/utils/collection_actions.dart";
import "package:locker/utils/date_time_util.dart";
class ManageSharedLinkWidget extends StatefulWidget {
final Collection? collection;
const ManageSharedLinkWidget({super.key, this.collection});
@override
State<ManageSharedLinkWidget> createState() => _ManageSharedLinkWidgetState();
}
class _ManageSharedLinkWidgetState extends State<ManageSharedLinkWidget> {
final GlobalKey sendLinkButtonKey = GlobalKey();
@override
void initState() {
super.initState();
}
@override
Widget build(BuildContext context) {
final isCollectEnabled =
widget.collection!.publicURLs.firstOrNull?.enableCollect ?? false;
final isDownloadEnabled =
widget.collection!.publicURLs.firstOrNull?.enableDownload ?? true;
final isPasswordEnabled =
widget.collection!.publicURLs.firstOrNull?.passwordEnabled ?? false;
final enteColorScheme = getEnteColorScheme(context);
final PublicURL url = widget.collection!.publicURLs.firstOrNull!;
final String urlValue =
CollectionService.instance.getPublicUrl(widget.collection!);
return Scaffold(
appBar: AppBar(
elevation: 0,
title: Text(context.l10n.manageLink),
),
body: SingleChildScrollView(
child: ListBody(
children: <Widget>[
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MenuItemWidget(
key: ValueKey("Allow collect $isCollectEnabled"),
captionedTextWidget: CaptionedTextWidget(
title: context.l10n.allowAddingFiles,
),
alignCaptionedTextToLeft: true,
menuItemColor: getEnteColorScheme(context).fillFaint,
trailingWidget: ToggleSwitchWidget(
value: () => isCollectEnabled,
onChanged: () async {
await _updateUrlSettings(
context,
{'enableCollect': !isCollectEnabled},
);
},
),
),
MenuSectionDescriptionWidget(
content: context.l10n.allowAddFilesDescription,
),
const SizedBox(height: 24),
MenuItemWidget(
alignCaptionedTextToLeft: true,
captionedTextWidget: CaptionedTextWidget(
title: context.l10n.linkExpiry,
subTitle: (url.hasExpiry
? (url.isExpired
? context.l10n.linkExpired
: context.l10n.linkEnabled)
: context.l10n.linkNeverExpires),
subTitleColor: url.isExpired ? warning500 : null,
),
trailingIcon: Icons.chevron_right,
menuItemColor: enteColorScheme.fillFaint,
surfaceExecutionStates: false,
onTap: () async {
// ignore: unawaited_futures
routeToPage(
context,
LinkExpiryPickerPage(widget.collection!),
).then((value) {
setState(() {});
});
},
),
url.hasExpiry
? MenuSectionDescriptionWidget(
content: url.isExpired
? context.l10n.expiredLinkInfo
: context.l10n.linkExpiresOn(
getFormattedTime(
DateTime.fromMicrosecondsSinceEpoch(
url.validTill,
),
),
),
)
: const SizedBox.shrink(),
const Padding(padding: EdgeInsets.only(top: 24)),
MenuItemWidget(
captionedTextWidget: CaptionedTextWidget(
title: context.l10n.linkDeviceLimit,
subTitle: url.deviceLimit == 0
? context.l10n.noDeviceLimit
: "${url.deviceLimit}",
),
trailingIcon: Icons.chevron_right,
menuItemColor: enteColorScheme.fillFaint,
alignCaptionedTextToLeft: true,
isBottomBorderRadiusRemoved: true,
onTap: () async {
// ignore: unawaited_futures
routeToPage(
context,
DeviceLimitPickerPage(widget.collection!),
).then((value) {
setState(() {});
});
},
surfaceExecutionStates: false,
),
DividerWidget(
dividerType: DividerType.menuNoIcon,
bgColor: getEnteColorScheme(context).fillFaint,
),
MenuItemWidget(
key: ValueKey("Allow downloads $isDownloadEnabled"),
captionedTextWidget: CaptionedTextWidget(
title: context.l10n.allowDownloads,
),
alignCaptionedTextToLeft: true,
isBottomBorderRadiusRemoved: true,
isTopBorderRadiusRemoved: true,
menuItemColor: getEnteColorScheme(context).fillFaint,
trailingWidget: ToggleSwitchWidget(
value: () => isDownloadEnabled,
onChanged: () async {
await _updateUrlSettings(
context,
{'enableDownload': !isDownloadEnabled},
);
if (isDownloadEnabled) {
// ignore: unawaited_futures
showErrorDialog(
context,
context.l10n.disableDownloadWarningTitle,
context.l10n.disableDownloadWarningBody,
);
}
},
),
),
DividerWidget(
dividerType: DividerType.menuNoIcon,
bgColor: getEnteColorScheme(context).fillFaint,
),
MenuItemWidget(
key: ValueKey("Password lock $isPasswordEnabled"),
captionedTextWidget: CaptionedTextWidget(
title: context.l10n.passwordLock,
),
alignCaptionedTextToLeft: true,
isTopBorderRadiusRemoved: true,
menuItemColor: getEnteColorScheme(context).fillFaint,
trailingWidget: ToggleSwitchWidget(
value: () => isPasswordEnabled,
onChanged: () async {
if (!isPasswordEnabled) {
// ignore: unawaited_futures
showTextInputDialog(
context,
title: context.l10n.setPasswordTitle,
submitButtonLabel: context.l10n.lockButtonLabel,
hintText: context.l10n.enterPassword,
isPasswordInput: true,
alwaysShowSuccessState: true,
onSubmit: (String password) async {
if (password.trim().isNotEmpty) {
final propToUpdate =
await _getEncryptedPassword(
password,
);
await _updateUrlSettings(
context,
propToUpdate,
showProgressDialog: false,
);
}
},
);
} else {
await _updateUrlSettings(
context,
{'disablePassword': true},
);
}
},
),
),
const SizedBox(
height: 24,
),
if (url.isExpired)
MenuItemWidget(
captionedTextWidget: CaptionedTextWidget(
title: context.l10n.linkExpired,
textColor: getEnteColorScheme(context).warning500,
),
leadingIcon: Icons.error_outline,
leadingIconColor: getEnteColorScheme(context).warning500,
menuItemColor: getEnteColorScheme(context).fillFaint,
isBottomBorderRadiusRemoved: true,
),
if (!url.isExpired)
MenuItemWidget(
captionedTextWidget: CaptionedTextWidget(
title: context.l10n.copyLink,
makeTextBold: true,
),
leadingIcon: Icons.copy,
menuItemColor: getEnteColorScheme(context).fillFaint,
showOnlyLoadingState: true,
onTap: () async {
await Clipboard.setData(ClipboardData(text: urlValue));
showShortToast(
context,
context.l10n.linkCopiedToClipboard,
);
},
isBottomBorderRadiusRemoved: true,
),
if (!url.isExpired)
DividerWidget(
dividerType: DividerType.menu,
bgColor: getEnteColorScheme(context).fillFaint,
),
if (!url.isExpired)
MenuItemWidget(
key: sendLinkButtonKey,
captionedTextWidget: CaptionedTextWidget(
title: context.l10n.sendLink,
makeTextBold: true,
),
leadingIcon: Icons.adaptive.share,
menuItemColor: getEnteColorScheme(context).fillFaint,
onTap: () async {
// ignore: unawaited_futures
await shareText(
urlValue,
context: context,
);
},
isTopBorderRadiusRemoved: true,
),
const SizedBox(height: 24),
MenuItemWidget(
captionedTextWidget: CaptionedTextWidget(
title: context.l10n.removeLink,
textColor: warning500,
makeTextBold: true,
),
leadingIcon: Icons.remove_circle_outline,
leadingIconColor: warning500,
menuItemColor: getEnteColorScheme(context).fillFaint,
surfaceExecutionStates: false,
onTap: () async {
final bool result = await CollectionActions.disableUrl(
context,
widget.collection!,
);
if (result && mounted) {
Navigator.of(context).pop();
if (widget.collection!.isQuickLinkCollection()) {
Navigator.of(context).pop();
}
}
},
),
],
),
),
],
),
),
);
}
Future<Map<String, dynamic>> _getEncryptedPassword(String pass) async {
final kekSalt = CryptoUtil.getSaltToDeriveKey();
final result = await CryptoUtil.deriveInteractiveKey(
utf8.encode(pass),
kekSalt,
);
return {
'passHash': CryptoUtil.bin2base64(result.key),
'nonce': CryptoUtil.bin2base64(kekSalt),
'memLimit': result.memLimit,
'opsLimit': result.opsLimit,
};
}
Future<void> _updateUrlSettings(
BuildContext context,
Map<String, dynamic> prop, {
bool showProgressDialog = true,
}) async {
final dialog = showProgressDialog
? createProgressDialog(context, context.l10n.pleaseWait)
: null;
await dialog?.show();
try {
await CollectionApiClient.instance
.updateShareUrl(widget.collection!, prop);
await dialog?.hide();
showShortToast(context, "Collection updated");
if (mounted) {
setState(() {});
}
} catch (e) {
await dialog?.hide();
await showGenericErrorDialog(context: context, error: e);
rethrow;
}
}
}

View File

@@ -1,79 +0,0 @@
import "package:ente_ui/theme/colors.dart";
import "package:ente_ui/theme/ente_theme.dart";
import 'package:flutter/material.dart';
import 'package:tuple/tuple.dart';
enum MoreCountType { small, mini, tiny, extra }
class MoreCountWidget extends StatelessWidget {
final MoreCountType type;
final bool thumbnailView;
final int count;
const MoreCountWidget(
this.count, {
super.key,
this.type = MoreCountType.mini,
this.thumbnailView = false,
});
@override
Widget build(BuildContext context) {
final colorScheme = getEnteColorScheme(context);
final displayChar = "+$count";
final Color decorationColor = thumbnailView
? backgroundElevated2Light
: colorScheme.backgroundElevated2;
final avatarStyle = getAvatarStyle(context, type);
final double size = avatarStyle.item1;
final TextStyle textStyle = thumbnailView
? avatarStyle.item2.copyWith(color: textFaintLight)
: avatarStyle.item2.copyWith(color: Colors.white);
return Container(
padding: const EdgeInsets.all(0.5),
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: thumbnailView
? strokeMutedDark
: getEnteColorScheme(context).strokeMuted,
width: 1.0,
strokeAlign: BorderSide.strokeAlignOutside,
),
),
child: SizedBox(
height: size,
width: size,
child: CircleAvatar(
backgroundColor: decorationColor,
child: Transform.scale(
scale: 0.85,
child: Text(
displayChar.toUpperCase(),
// fixed color
style: textStyle,
),
),
),
),
);
}
Tuple2<double, TextStyle> getAvatarStyle(
BuildContext context,
MoreCountType type,
) {
final enteTextTheme = getEnteTextTheme(context);
switch (type) {
case MoreCountType.small:
return Tuple2(32.0, enteTextTheme.small);
case MoreCountType.mini:
return Tuple2(24.0, enteTextTheme.mini);
case MoreCountType.tiny:
return Tuple2(18.0, enteTextTheme.tiny);
case MoreCountType.extra:
return Tuple2(18.0, enteTextTheme.tiny);
}
}
}

View File

@@ -1,145 +0,0 @@
import "package:ente_ui/components/captioned_text_widget.dart";
import "package:ente_ui/components/divider_widget.dart";
import "package:ente_ui/components/menu_item_widget.dart";
import "package:ente_ui/components/separators.dart";
import "package:ente_ui/components/title_bar_title_widget.dart";
import "package:ente_ui/components/title_bar_widget.dart";
import "package:ente_ui/theme/ente_theme.dart";
import "package:ente_ui/utils/dialog_util.dart";
import 'package:flutter/material.dart';
import "package:locker/core/constants.dart";
import "package:locker/l10n/l10n.dart";
import "package:locker/services/collections/collections_api_client.dart";
import "package:locker/services/collections/models/collection.dart";
class DeviceLimitPickerPage extends StatelessWidget {
final Collection collection;
const DeviceLimitPickerPage(this.collection, {super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: CustomScrollView(
primary: false,
slivers: <Widget>[
TitleBarWidget(
flexibleSpaceTitle: TitleBarTitleWidget(
title: context.l10n.linkDeviceLimit,
),
),
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 20,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ClipRRect(
borderRadius:
const BorderRadius.all(Radius.circular(8)),
child: ItemsWidget(collection),
),
],
),
);
},
childCount: 1,
),
),
const SliverPadding(padding: EdgeInsets.symmetric(vertical: 12)),
],
),
);
}
}
class ItemsWidget extends StatefulWidget {
final Collection collection;
const ItemsWidget(this.collection, {super.key});
@override
State<ItemsWidget> createState() => _ItemsWidgetState();
}
class _ItemsWidgetState extends State<ItemsWidget> {
late int currentDeviceLimit;
late int initialDeviceLimit;
List<Widget> items = [];
bool isCustomLimit = false;
@override
void initState() {
currentDeviceLimit = widget.collection.publicURLs.first.deviceLimit;
initialDeviceLimit = currentDeviceLimit;
if (!publicLinkDeviceLimits.contains(currentDeviceLimit)) {
isCustomLimit = true;
}
super.initState();
}
@override
Widget build(BuildContext context) {
items.clear();
if (isCustomLimit) {
items.add(
_menuItemForPicker(initialDeviceLimit),
);
}
for (int deviceLimit in publicLinkDeviceLimits) {
items.add(
_menuItemForPicker(deviceLimit),
);
}
items = addSeparators(
items,
DividerWidget(
dividerType: DividerType.menuNoIcon,
bgColor: getEnteColorScheme(context).fillFaint,
),
);
return Column(
mainAxisSize: MainAxisSize.min,
children: items,
);
}
Widget _menuItemForPicker(int deviceLimit) {
return MenuItemWidget(
key: ValueKey(deviceLimit),
menuItemColor: getEnteColorScheme(context).fillFaint,
captionedTextWidget: CaptionedTextWidget(
title: deviceLimit == 0 ? context.l10n.noDeviceLimit : "$deviceLimit",
),
trailingIcon: currentDeviceLimit == deviceLimit ? Icons.check : null,
alignCaptionedTextToLeft: true,
isTopBorderRadiusRemoved: true,
isBottomBorderRadiusRemoved: true,
showOnlyLoadingState: true,
onTap: () async {
await _updateUrlSettings(context, {
'deviceLimit': deviceLimit,
}).then(
(value) => setState(() {
currentDeviceLimit = deviceLimit;
}),
);
},
);
}
Future<void> _updateUrlSettings(
BuildContext context,
Map<String, dynamic> prop,
) async {
try {
await CollectionApiClient.instance
.updateShareUrl(widget.collection, prop);
} catch (e) {
await showGenericErrorDialog(context: context, error: e);
rethrow;
}
}
}

View File

@@ -1,168 +0,0 @@
import "package:ente_ui/components/captioned_text_widget.dart";
import "package:ente_ui/components/divider_widget.dart";
import "package:ente_ui/components/menu_item_widget.dart";
import "package:ente_ui/components/separators.dart";
import "package:ente_ui/components/title_bar_title_widget.dart";
import "package:ente_ui/components/title_bar_widget.dart";
import "package:ente_ui/theme/ente_theme.dart";
import "package:ente_ui/utils/dialog_util.dart";
import 'package:flutter/material.dart';
import "package:locker/l10n/l10n.dart";
import "package:locker/services/collections/collections_api_client.dart";
import "package:locker/services/collections/models/collection.dart";
import "package:locker/ui/viewer/date/date_time_picker.dart";
import "package:tuple/tuple.dart";
class LinkExpiryPickerPage extends StatelessWidget {
final Collection collection;
const LinkExpiryPickerPage(this.collection, {super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: CustomScrollView(
primary: false,
slivers: <Widget>[
TitleBarWidget(
flexibleSpaceTitle: TitleBarTitleWidget(
title: context.l10n.linkExpiry,
),
),
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 20,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ClipRRect(
borderRadius:
const BorderRadius.all(Radius.circular(8)),
child: ItemsWidget(collection),
),
],
),
);
},
childCount: 1,
),
),
const SliverPadding(padding: EdgeInsets.symmetric(vertical: 12)),
],
),
);
}
}
class ItemsWidget extends StatefulWidget {
final Collection collection;
const ItemsWidget(this.collection, {super.key});
@override
State<ItemsWidget> createState() => _ItemsWidgetState();
}
class _ItemsWidgetState extends State<ItemsWidget> {
// index, title, milliseconds in future post which link should expire (when >0)
late final List<Tuple2<String, int>> _expiryOptions = [
Tuple2(context.l10n.never, 0),
Tuple2(context.l10n.after1Hour, const Duration(hours: 1).inMicroseconds),
Tuple2(context.l10n.after1Day, const Duration(days: 1).inMicroseconds),
Tuple2(context.l10n.after1Week, const Duration(days: 7).inMicroseconds),
// todo: make this time calculation perfect
Tuple2(context.l10n.after1Month, const Duration(days: 30).inMicroseconds),
Tuple2(context.l10n.after1Year, const Duration(days: 365).inMicroseconds),
Tuple2(context.l10n.custom, -1),
];
@override
Widget build(BuildContext context) {
List<Widget> items = [];
for (Tuple2<String, int> expiryOpiton in _expiryOptions) {
items.add(
_menuItemForPicker(context, expiryOpiton),
);
}
items = addSeparators(
items,
DividerWidget(
dividerType: DividerType.menuNoIcon,
bgColor: getEnteColorScheme(context).fillFaint,
),
);
return Column(
mainAxisSize: MainAxisSize.min,
children: items,
);
}
Widget _menuItemForPicker(
BuildContext context,
Tuple2<String, int> expiryOpiton,
) {
return MenuItemWidget(
menuItemColor: getEnteColorScheme(context).fillFaint,
captionedTextWidget: CaptionedTextWidget(
title: expiryOpiton.item1,
),
alignCaptionedTextToLeft: true,
isTopBorderRadiusRemoved: true,
isBottomBorderRadiusRemoved: true,
alwaysShowSuccessState: true,
surfaceExecutionStates: expiryOpiton.item2 == -1 ? false : true,
onTap: () async {
int newValidTill = -1;
final int expireAfterInMicroseconds = expiryOpiton.item2;
// need to manually select time
if (expireAfterInMicroseconds < 0) {
final now = DateTime.now();
final DateTime? picked = await showDatePickerSheet(
context,
initialDate: now,
minDate: now,
);
final timeInMicrosecondsFromEpoch = picked?.microsecondsSinceEpoch;
if (timeInMicrosecondsFromEpoch != null) {
newValidTill = timeInMicrosecondsFromEpoch;
}
} else if (expireAfterInMicroseconds == 0) {
// no expiry
newValidTill = 0;
} else {
newValidTill =
DateTime.now().microsecondsSinceEpoch + expireAfterInMicroseconds;
}
if (newValidTill >= 0) {
debugPrint(
"Setting expire date to ${DateTime.fromMicrosecondsSinceEpoch(newValidTill)}",
);
await updateTime(newValidTill, context);
}
},
);
}
Future<void> updateTime(int newValidTill, BuildContext context) async {
await _updateUrlSettings(
context,
{'validTill': newValidTill},
);
}
Future<void> _updateUrlSettings(
BuildContext context,
Map<String, dynamic> prop,
) async {
try {
await CollectionApiClient.instance
.updateShareUrl(widget.collection, prop);
} catch (e) {
await showGenericErrorDialog(context: context, error: e);
rethrow;
}
}
}

View File

@@ -1,404 +0,0 @@
import "package:ente_sharing/models/user.dart";
import "package:ente_sharing/user_avator_widget.dart";
import "package:ente_ui/components/captioned_text_widget.dart";
import "package:ente_ui/components/divider_widget.dart";
import "package:ente_ui/components/menu_item_widget.dart";
import "package:ente_ui/components/menu_section_description_widget.dart";
import "package:ente_ui/components/menu_section_title.dart";
import "package:ente_ui/theme/colors.dart";
import "package:ente_ui/theme/ente_theme.dart";
import "package:ente_ui/utils/toast_util.dart";
import "package:ente_utils/ente_utils.dart";
import "package:flutter/material.dart";
import "package:flutter/services.dart";
import "package:locker/extensions/user_extension.dart";
import "package:locker/l10n/l10n.dart";
import "package:locker/services/collections/collections_service.dart";
import "package:locker/services/collections/models/collection.dart";
import "package:locker/services/configuration.dart";
import "package:locker/ui/sharing/add_participant_page.dart";
import "package:locker/ui/sharing/album_participants_page.dart";
import "package:locker/ui/sharing/album_share_info_widget.dart";
import "package:locker/ui/sharing/manage_album_participant.dart";
import "package:locker/ui/sharing/manage_links_widget.dart";
import "package:locker/utils/collection_actions.dart";
class ShareCollectionPage extends StatefulWidget {
final Collection collection;
const ShareCollectionPage({super.key, required this.collection});
@override
State<ShareCollectionPage> createState() => _ShareCollectionPageState();
}
class _ShareCollectionPageState extends State<ShareCollectionPage> {
late List<User?> _sharees;
Future<void> _navigateToManageUser() async {
if (_sharees.length == 1) {
await routeToPage(
context,
ManageIndividualParticipant(
collection: widget.collection,
user: _sharees.first!,
),
);
} else {
await routeToPage(
context,
AlbumParticipantsPage(widget.collection),
);
}
if (mounted) {
setState(() => {});
}
}
@override
Widget build(BuildContext context) {
final bool hasUrl = widget.collection.hasLink;
final bool hasExpired =
widget.collection.publicURLs.firstOrNull?.isExpired ?? false;
_sharees = widget.collection.sharees;
final children = <Widget>[];
children.add(
MenuSectionTitle(
title: context.l10n.shareWithPeopleSectionTitle(_sharees.length),
iconData: Icons.workspaces,
),
);
children.add(
EmailItemWidget(
widget.collection,
onTap: _navigateToManageUser,
),
);
children.add(
MenuItemWidget(
captionedTextWidget: CaptionedTextWidget(
title: context.l10n.addViewer,
makeTextBold: true,
),
leadingIcon: Icons.add,
menuItemColor: getEnteColorScheme(context).fillFaint,
isTopBorderRadiusRemoved: _sharees.isNotEmpty,
isBottomBorderRadiusRemoved: true,
onTap: () async {
// ignore: unawaited_futures
routeToPage(
context,
AddParticipantPage(
[widget.collection],
const [ActionTypesToShow.addViewer],
),
).then(
(value) => {
if (mounted) {setState(() => {})},
},
);
},
),
);
children.add(
DividerWidget(
dividerType: DividerType.menu,
bgColor: getEnteColorScheme(context).fillFaint,
),
);
children.add(
MenuItemWidget(
captionedTextWidget: CaptionedTextWidget(
title: context.l10n.addCollaborator,
makeTextBold: true,
),
leadingIcon: Icons.add,
menuItemColor: getEnteColorScheme(context).fillFaint,
isTopBorderRadiusRemoved: true,
onTap: () async {
// ignore: unawaited_futures
routeToPage(
context,
AddParticipantPage(
[widget.collection],
const [ActionTypesToShow.addCollaborator],
),
).then(
(value) => {
if (mounted) {setState(() => {})},
},
);
},
),
);
if (_sharees.isEmpty && !hasUrl) {
children.add(
MenuSectionDescriptionWidget(
content: context.l10n.sharedCollectionSectionDescription,
),
);
}
children.addAll([
const SizedBox(
height: 24,
),
MenuSectionTitle(
title:
hasUrl ? context.l10n.publicLinkEnabled : context.l10n.shareALink,
iconData: Icons.public,
),
]);
if (hasUrl) {
if (hasExpired) {
children.add(
MenuItemWidget(
captionedTextWidget: CaptionedTextWidget(
title: context.l10n.linkHasExpired,
textColor: getEnteColorScheme(context).warning500,
),
leadingIcon: Icons.error_outline,
leadingIconColor: getEnteColorScheme(context).warning500,
menuItemColor: getEnteColorScheme(context).fillFaint,
isBottomBorderRadiusRemoved: true,
),
);
} else {
final String url =
CollectionService.instance.getPublicUrl(widget.collection);
children.addAll(
[
MenuItemWidget(
captionedTextWidget: CaptionedTextWidget(
title: context.l10n.copyLink,
makeTextBold: true,
),
leadingIcon: Icons.copy,
menuItemColor: getEnteColorScheme(context).fillFaint,
showOnlyLoadingState: true,
onTap: () async {
await Clipboard.setData(ClipboardData(text: url));
showShortToast(context, "Link copied to clipboard");
},
isBottomBorderRadiusRemoved: true,
),
DividerWidget(
dividerType: DividerType.menu,
bgColor: getEnteColorScheme(context).fillFaint,
),
MenuItemWidget(
captionedTextWidget: CaptionedTextWidget(
title: context.l10n.sendLink,
makeTextBold: true,
),
leadingIcon: Icons.adaptive.share,
menuItemColor: getEnteColorScheme(context).fillFaint,
onTap: () async {
// ignore: unawaited_futures
await shareText(
url,
context: context,
);
},
isTopBorderRadiusRemoved: true,
isBottomBorderRadiusRemoved: true,
),
],
);
}
children.addAll(
[
DividerWidget(
dividerType: DividerType.menu,
bgColor: getEnteColorScheme(context).fillFaint,
),
MenuItemWidget(
captionedTextWidget: CaptionedTextWidget(
title: context.l10n.manageLink,
makeTextBold: true,
),
leadingIcon: Icons.link,
trailingIcon: Icons.navigate_next,
menuItemColor: getEnteColorScheme(context).fillFaint,
trailingIconIsMuted: true,
onTap: () async {
// ignore: unawaited_futures
routeToPage(
context,
ManageSharedLinkWidget(collection: widget.collection),
).then(
(value) => {
if (mounted) {setState(() => {})},
},
);
},
isTopBorderRadiusRemoved: true,
),
const SizedBox(height: 24),
MenuItemWidget(
captionedTextWidget: CaptionedTextWidget(
title: context.l10n.removeLink,
textColor: warning500,
makeTextBold: true,
),
leadingIcon: Icons.remove_circle_outline,
leadingIconColor: warning500,
menuItemColor: getEnteColorScheme(context).fillFaint,
surfaceExecutionStates: false,
onTap: () async {
final bool result = await CollectionActions.disableUrl(
context,
widget.collection,
);
if (result && mounted) {
Navigator.of(context).pop();
if (widget.collection.isQuickLinkCollection()) {
Navigator.of(context).pop();
}
}
},
),
],
);
} else {
children.addAll(
[
MenuItemWidget(
captionedTextWidget: CaptionedTextWidget(
title: context.l10n.createPublicLink,
makeTextBold: true,
),
leadingIcon: Icons.link,
menuItemColor: getEnteColorScheme(context).fillFaint,
showOnlyLoadingState: true,
onTap: () async {
final bool result =
await CollectionActions.enableUrl(context, widget.collection);
if (result && mounted) {
setState(() => {});
}
},
),
],
);
}
return Scaffold(
appBar: AppBar(
title: Text(
widget.collection.name ?? "Collection",
style:
Theme.of(context).textTheme.headlineSmall?.copyWith(fontSize: 16),
),
elevation: 0,
centerTitle: false,
),
body: SingleChildScrollView(
child: ListBody(
children: <Widget>[
Padding(
padding:
const EdgeInsets.symmetric(vertical: 4.0, horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: children,
),
),
],
),
),
);
}
}
class EmailItemWidget extends StatelessWidget {
final Collection collection;
final Function? onTap;
const EmailItemWidget(
this.collection, {
this.onTap,
super.key,
});
@override
Widget build(BuildContext context) {
if (collection.getSharees().isEmpty) {
return const SizedBox.shrink();
} else if (collection.getSharees().length == 1) {
final User? user = collection.getSharees().firstOrNull;
return Column(
mainAxisAlignment: MainAxisAlignment.start,
children: [
MenuItemWidget(
captionedTextWidget: CaptionedTextWidget(
title: user?.displayName ?? user?.email ?? '',
),
leadingIconWidget: UserAvatarWidget(
collection.getSharees().first,
thumbnailView: false,
config: Configuration.instance,
),
leadingIconSize: 24,
menuItemColor: getEnteColorScheme(context).fillFaint,
trailingIconIsMuted: true,
trailingIcon: Icons.chevron_right,
onTap: () async {
if (onTap != null) {
onTap!();
}
},
isBottomBorderRadiusRemoved: true,
),
DividerWidget(
dividerType: DividerType.menu,
bgColor: getEnteColorScheme(context).fillFaint,
),
],
);
} else {
return Column(
mainAxisAlignment: MainAxisAlignment.start,
children: [
MenuItemWidget(
captionedTextWidget: Flexible(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 0),
child: SizedBox(
height: 24,
child: AlbumSharesIcons(
sharees: collection.getSharees(),
padding: const EdgeInsets.all(0),
limitCountTo: 10,
type: AvatarType.mini,
removeBorder: false,
),
),
),
),
alignCaptionedTextToLeft: true,
// leadingIcon: Icons.people_outline,
menuItemColor: getEnteColorScheme(context).fillFaint,
trailingIconIsMuted: true,
trailingIcon: Icons.chevron_right,
onTap: () async {
if (onTap != null) {
onTap!();
}
},
isBottomBorderRadiusRemoved: true,
),
DividerWidget(
dividerType: DividerType.menu,
bgColor: getEnteColorScheme(context).fillFaint,
),
],
);
}
}
}

View File

@@ -1,227 +0,0 @@
import "package:ente_ui/theme/ente_theme.dart";
import "package:flutter/cupertino.dart";
import "package:flutter/material.dart";
import "package:locker/l10n/l10n.dart";
Future<DateTime?> showDatePickerSheet(
BuildContext context, {
required DateTime initialDate,
DateTime? maxDate,
DateTime? minDate,
bool startWithTime = false,
}) async {
final colorScheme = getEnteColorScheme(context);
final sheet = Container(
decoration: BoxDecoration(
color: colorScheme.backgroundElevated,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(12),
topRight: Radius.circular(12),
),
),
child: Padding(
padding: const EdgeInsets.all(8.0),
child: DateTimePickerWidget(
(DateTime dateTime) {
Navigator.of(context).pop(dateTime);
},
() {
Navigator.of(context).pop(null);
},
initialDate,
minDateTime: minDate,
maxDateTime: maxDate,
),
),
);
final newDate = await showModalBottomSheet<DateTime?>(
context: context,
isScrollControlled: true,
builder: (context) => sheet,
);
return newDate;
}
class DateTimePickerWidget extends StatefulWidget {
final Function(DateTime) onDateTimeSelected;
final Function() onCancel;
final DateTime initialDateTime;
final DateTime? maxDateTime;
final DateTime? minDateTime;
final bool startWithTime;
const DateTimePickerWidget(
this.onDateTimeSelected,
this.onCancel,
this.initialDateTime, {
this.maxDateTime,
this.minDateTime,
this.startWithTime = false,
super.key,
});
@override
State<DateTimePickerWidget> createState() => _DateTimePickerWidgetState();
}
class _DateTimePickerWidgetState extends State<DateTimePickerWidget> {
late DateTime _selectedDateTime;
bool _showTimePicker = false;
@override
void initState() {
super.initState();
_showTimePicker = widget.startWithTime;
_selectedDateTime = widget.initialDateTime;
}
@override
Widget build(BuildContext context) {
final colorScheme = getEnteColorScheme(context);
return Container(
color: colorScheme.backgroundElevated,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Header
Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: Align(
alignment: Alignment.centerLeft,
child: Text(
_showTimePicker
? context.l10n.selectTime
: context.l10n.selectDate,
style: TextStyle(
color: colorScheme.textBase,
fontSize: 16,
),
),
),
),
// Date/Time Picker
Container(
height: 220,
decoration: BoxDecoration(
color: colorScheme.backgroundElevated2,
borderRadius: BorderRadius.circular(12),
),
child: CupertinoTheme(
data: CupertinoThemeData(
brightness: Brightness.dark,
textTheme: CupertinoTextThemeData(
dateTimePickerTextStyle: TextStyle(
color: colorScheme.textBase,
fontSize: 22,
),
),
),
child: CupertinoDatePicker(
key: ValueKey(_showTimePicker),
mode: _showTimePicker
? CupertinoDatePickerMode.time
: CupertinoDatePickerMode.date,
initialDateTime: _selectedDateTime,
minimumDate: widget.minDateTime ?? DateTime(1800),
maximumDate: widget.maxDateTime ?? DateTime(2200),
use24hFormat: MediaQuery.of(context).alwaysUse24HourFormat,
showDayOfWeek: !_showTimePicker,
onDateTimeChanged: (DateTime newDateTime) {
setState(() {
if (_showTimePicker) {
// Keep the date but update the time
_selectedDateTime = DateTime(
_selectedDateTime.year,
_selectedDateTime.month,
_selectedDateTime.day,
newDateTime.hour,
newDateTime.minute,
);
} else {
// Keep the time but update the date
_selectedDateTime = DateTime(
newDateTime.year,
newDateTime.month,
newDateTime.day,
_selectedDateTime.hour,
_selectedDateTime.minute,
);
}
// Ensure the selected date doesn't exceed maxDateTime or minDateTime
if (widget.minDateTime != null &&
_selectedDateTime.isBefore(widget.minDateTime!)) {
_selectedDateTime = widget.minDateTime!;
}
if (widget.maxDateTime != null &&
_selectedDateTime.isAfter(widget.maxDateTime!)) {
_selectedDateTime = widget.maxDateTime!;
}
});
},
),
),
),
// Buttons
Padding(
padding: const EdgeInsets.symmetric(vertical: 12.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// Cancel Button
CupertinoButton(
padding: EdgeInsets.zero,
child: Text(
_showTimePicker
? context.l10n.previous
: context.l10n.cancel,
style: TextStyle(
color: colorScheme.textBase,
fontSize: 14,
),
),
onPressed: () {
if (_showTimePicker) {
// Go back to date picker
setState(() {
_showTimePicker = false;
});
} else {
widget.onCancel();
}
},
),
// Next/Done Button
CupertinoButton(
padding: EdgeInsets.zero,
child: Text(
_showTimePicker ? context.l10n.done : context.l10n.next,
style: TextStyle(
color: colorScheme.primary700,
fontSize: 14,
fontWeight: FontWeight.bold,
),
),
onPressed: () {
if (_showTimePicker) {
// We're done, call the callback
widget.onDateTimeSelected(_selectedDateTime);
} else {
// Move to time picker
setState(() {
_showTimePicker = true;
});
}
},
),
],
),
),
],
),
);
}
}

View File

@@ -1,24 +1,10 @@
import "dart:async";
import "package:ente_accounts/services/user_service.dart";
import "package:ente_sharing/models/user.dart";
import "package:ente_ui/components/action_sheet_widget.dart";
import 'package:ente_ui/components/buttons/button_widget.dart';
import 'package:ente_ui/components/buttons/models/button_type.dart';
import "package:ente_ui/components/dialog_widget.dart";
import "package:ente_ui/components/progress_dialog.dart";
import "package:ente_ui/components/user_dialogs.dart";
import 'package:ente_ui/utils/dialog_util.dart';
import "package:ente_utils/email_util.dart";
import "package:ente_utils/share_utils.dart";
import 'package:flutter/material.dart';
import "package:locker/core/errors.dart";
import "package:locker/extensions/user_extension.dart";
import 'package:locker/l10n/l10n.dart';
import "package:locker/services/collections/collections_api_client.dart";
import 'package:locker/services/collections/collections_service.dart';
import 'package:locker/services/collections/models/collection.dart';
import "package:locker/services/configuration.dart";
import 'package:locker/services/collections/models/collection.dart';
import 'package:locker/utils/snack_bar_utils.dart';
import 'package:logging/logging.dart';
@@ -171,336 +157,4 @@ class CollectionActions {
);
}
}
static Future<void> leaveCollection(
BuildContext context,
Collection collection, {
VoidCallback? onSuccess,
}) async {
final actionResult = await showActionSheet(
context: context,
buttons: [
ButtonWidget(
buttonType: ButtonType.critical,
isInAlert: true,
shouldStickToDarkTheme: true,
buttonAction: ButtonAction.first,
shouldSurfaceExecutionStates: true,
labelText: context.l10n.leaveCollection,
onTap: () async {
await CollectionApiClient.instance.leaveCollection(collection);
},
),
ButtonWidget(
buttonType: ButtonType.secondary,
buttonAction: ButtonAction.cancel,
isInAlert: true,
shouldStickToDarkTheme: true,
labelText: context.l10n.cancel,
),
],
title: context.l10n.leaveCollection,
body: context.l10n.filesAddedByYouWillBeRemovedFromTheCollection,
);
if (actionResult?.action != null && context.mounted) {
if (actionResult!.action == ButtonAction.error) {
await showGenericErrorDialog(
context: context,
error: actionResult.exception,
);
} else if (actionResult.action == ButtonAction.first) {
onSuccess?.call();
Navigator.of(context).pop();
SnackBarUtils.showInfoSnackBar(
context,
"Leave collection successfully",
);
}
}
}
static Future<bool> enableUrl(
BuildContext context,
Collection collection, {
bool enableCollect = false,
}) async {
try {
await CollectionApiClient.instance.createShareUrl(
collection,
enableCollect: enableCollect,
);
return true;
} catch (e) {
if (e is SharingNotPermittedForFreeAccountsError) {
await _showUnSupportedAlert(context);
} else {
_logger.severe("Failed to update shareUrl collection", e);
await showGenericErrorDialog(context: context, error: e);
}
return false;
}
}
static Future<bool> disableUrl(
BuildContext context,
Collection collection,
) async {
final actionResult = await showActionSheet(
context: context,
buttons: [
ButtonWidget(
buttonType: ButtonType.critical,
isInAlert: true,
shouldStickToDarkTheme: true,
buttonAction: ButtonAction.first,
shouldSurfaceExecutionStates: true,
labelText: "Yes, remove",
onTap: () async {
await CollectionApiClient.instance.disableShareUrl(collection);
},
),
ButtonWidget(
buttonType: ButtonType.secondary,
buttonAction: ButtonAction.cancel,
isInAlert: true,
shouldStickToDarkTheme: true,
labelText: context.l10n.cancel,
),
],
title: "Remove public link",
body:
"This will remove the public link for accessing \"${collection.name}\".",
);
if (actionResult?.action != null) {
if (actionResult!.action == ButtonAction.error) {
await showGenericErrorDialog(
context: context,
error: actionResult.exception,
);
}
return actionResult.action == ButtonAction.first;
} else {
return false;
}
}
static Future<void> _showUnSupportedAlert(BuildContext context) async {
final AlertDialog alert = AlertDialog(
title: const Text("Sorry"),
content: const Text(
"You need an active paid subscription to enable sharing.",
),
actions: [
ButtonWidget(
buttonType: ButtonType.primary,
isInAlert: true,
shouldStickToDarkTheme: false,
buttonAction: ButtonAction.first,
shouldSurfaceExecutionStates: true,
labelText: "Subscribe",
onTap: () async {
// TODO: If we are having subscriptions for locker
// Navigator.of(context).push(
// MaterialPageRoute(
// builder: (BuildContext context) {
// return getSubscriptionPage();
// },
// ),
// ).ignore();
Navigator.of(context).pop();
},
),
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: ButtonWidget(
buttonType: ButtonType.secondary,
buttonAction: ButtonAction.cancel,
isInAlert: true,
shouldStickToDarkTheme: false,
labelText: context.l10n.ok,
),
),
],
);
return showDialog(
useRootNavigator: false,
context: context,
builder: (BuildContext context) {
return alert;
},
barrierDismissible: true,
);
}
Future<bool> doesEmailHaveAccount(
BuildContext context,
String email, {
bool showProgress = false,
}) async {
ProgressDialog? dialog;
String? publicKey;
if (showProgress) {
dialog = createProgressDialog(
context,
context.l10n.sharing,
isDismissible: true,
);
await dialog.show();
}
try {
publicKey = await UserService.instance.getPublicKey(email);
} catch (e) {
await dialog?.hide();
_logger.severe("Failed to get public key", e);
await showGenericErrorDialog(context: context, error: e);
return false;
}
// getPublicKey can return null when no user is associated with given
// email id
if (publicKey == null || publicKey == '') {
// todo: neeraj replace this as per the design where a new screen
// is used for error. Do this change along with handling of network errors
await showInviteDialog(context, email);
return false;
} else {
return true;
}
}
// addEmailToCollection returns true if add operation was successful
Future<bool> addEmailToCollection(
BuildContext context,
Collection collection,
String email,
CollectionParticipantRole role, {
bool showProgress = false,
}) async {
if (!isValidEmail(email)) {
await showErrorDialog(
context,
context.l10n.invalidEmailAddress,
context.l10n.enterValidEmail,
);
return false;
} else if (email.trim() == Configuration.instance.getEmail()) {
await showErrorDialog(
context,
context.l10n.oops,
context.l10n.youCannotShareWithYourself,
);
return false;
}
ProgressDialog? dialog;
String? publicKey;
if (showProgress) {
dialog = createProgressDialog(
context,
context.l10n.sharing,
isDismissible: true,
);
await dialog.show();
}
try {
publicKey = await UserService.instance.getPublicKey(email);
} catch (e) {
await dialog?.hide();
_logger.severe("Failed to get public key", e);
await showGenericErrorDialog(context: context, error: e);
return false;
}
// getPublicKey can return null when no user is associated with given
// email id
if (publicKey == null || publicKey == '') {
// todo: neeraj replace this as per the design where a new screen
// is used for error. Do this change along with handling of network errors
await showDialogWidget(
context: context,
title: context.l10n.inviteToEnte,
icon: Icons.info_outline,
body: context.l10n.emailNoEnteAccount(email),
isDismissible: true,
buttons: [
ButtonWidget(
buttonType: ButtonType.neutral,
icon: Icons.adaptive.share,
labelText: context.l10n.sendInvite,
isInAlert: true,
onTap: () async {
unawaited(
shareText(
context.l10n.shareTextRecommendUsingEnte,
),
);
},
),
],
);
return false;
} else {
try {
final newSharees = await CollectionApiClient.instance
.share(collection.id, email, publicKey, role);
await dialog?.hide();
collection.updateSharees(newSharees);
return true;
} catch (e) {
await dialog?.hide();
if (e is SharingNotPermittedForFreeAccountsError) {
await _showUnSupportedAlert(context);
} else {
_logger.severe("failed to share collection", e);
await showGenericErrorDialog(context: context, error: e);
}
return false;
}
}
}
// removeParticipant remove the user from a share album
Future<bool> removeParticipant(
BuildContext context,
Collection collection,
User user,
) async {
final actionResult = await showActionSheet(
context: context,
buttons: [
ButtonWidget(
buttonType: ButtonType.critical,
isInAlert: true,
shouldStickToDarkTheme: true,
buttonAction: ButtonAction.first,
shouldSurfaceExecutionStates: true,
labelText: context.l10n.yesRemove,
onTap: () async {
final newSharees = await CollectionApiClient.instance
.unshare(collection.id, user.email);
collection.updateSharees(newSharees);
},
),
ButtonWidget(
buttonType: ButtonType.secondary,
buttonAction: ButtonAction.cancel,
isInAlert: true,
shouldStickToDarkTheme: true,
labelText: context.l10n.cancel,
),
],
title: context.l10n.removeWithQuestionMark,
body: context.l10n.removeParticipantBody(user.displayName ?? user.email),
);
if (actionResult?.action != null) {
if (actionResult!.action == ButtonAction.error) {
await showGenericErrorDialog(
context: context,
error: actionResult.exception,
);
}
return actionResult.action == ButtonAction.first;
}
return false;
}
}

View File

@@ -66,7 +66,7 @@ packages:
source: hosted
version: "2.13.0"
bip39:
dependency: "direct main"
dependency: transitive
description:
name: bip39
sha256: de1ee27ebe7d96b84bb3a04a4132a0a3007dcdd5ad27dd14aa87a29d97c45edc
@@ -146,7 +146,7 @@ packages:
source: hosted
version: "0.3.4+2"
crypto:
dependency: "direct main"
dependency: transitive
description:
name: crypto
sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855"
@@ -202,7 +202,7 @@ packages:
source: hosted
version: "2.1.1"
dotted_border:
dependency: "direct main"
dependency: transitive
description:
name: dotted_border
sha256: "99b091ec6891ba0c5331fdc2b502993c7c108f898995739a73c6845d71dad70c"
@@ -254,13 +254,6 @@ packages:
relative: true
source: path
version: "1.0.0"
ente_legacy:
dependency: "direct main"
description:
path: "../../packages/legacy"
relative: true
source: path
version: "1.0.0"
ente_lock_screen:
dependency: "direct main"
description:
@@ -282,13 +275,6 @@ packages:
relative: true
source: path
version: "1.0.0"
ente_sharing:
dependency: "direct main"
description:
path: "../../packages/sharing"
relative: true
source: path
version: "1.0.0"
ente_strings:
dependency: "direct main"
description:
@@ -569,14 +555,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.1.3"
flutter_svg:
dependency: "direct main"
description:
name: flutter_svg
sha256: b9c2ad5872518a27507ab432d1fb97e8813b05f0fc693f9d40fad06d073e0678
url: "https://pub.dev"
source: hosted
version: "2.2.1"
flutter_test:
dependency: "direct dev"
description: flutter
@@ -628,7 +606,7 @@ packages:
source: hosted
version: "0.15.6"
http:
dependency: transitive
dependency: "direct main"
description:
name: http
sha256: "2c11f3f94c687ee9bad77c171151672986360b2b001d109814ee7140b2cf261b"
@@ -660,7 +638,7 @@ packages:
source: hosted
version: "4.5.4"
intl:
dependency: "direct main"
dependency: transitive
description:
name: intl
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
@@ -955,14 +933,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.9.1"
path_parsing:
dependency: transitive
description:
name: path_parsing
sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
path_provider:
dependency: "direct main"
description:
@@ -1321,7 +1291,7 @@ packages:
source: hosted
version: "1.4.1"
styled_text:
dependency: transitive
dependency: "direct main"
description:
name: styled_text
sha256: fd624172cf629751b4f171dd0ecf9acf02a06df3f8a81bb56c0caa4f1df706c3
@@ -1361,7 +1331,7 @@ packages:
source: hosted
version: "0.5.0"
tuple:
dependency: "direct main"
dependency: transitive
description:
name: tuple
sha256: a97ce2013f240b2f3807bcbaf218765b6f301c3eff91092bcfa23a039e7dd151
@@ -1464,30 +1434,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.5.1"
vector_graphics:
dependency: transitive
description:
name: vector_graphics
sha256: a4f059dc26fc8295b5921376600a194c4ec7d55e72f2fe4c7d2831e103d461e6
url: "https://pub.dev"
source: hosted
version: "1.1.19"
vector_graphics_codec:
dependency: transitive
description:
name: vector_graphics_codec
sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146"
url: "https://pub.dev"
source: hosted
version: "1.1.13"
vector_graphics_compiler:
dependency: transitive
description:
name: vector_graphics_compiler
sha256: d354a7ec6931e6047785f4db12a1f61ec3d43b207fc0790f863818543f8ff0dc
url: "https://pub.dev"
source: hosted
version: "1.1.19"
vector_math:
dependency: transitive
description:
@@ -1578,4 +1524,4 @@ packages:
version: "3.1.3"
sdks:
dart: ">=3.8.0 <4.0.0"
flutter: ">=3.29.0"
flutter: ">=3.27.0"

View File

@@ -8,11 +8,8 @@ environment:
dependencies:
adaptive_theme: ^3.6.0
bip39: ^1.0.6
collection: ^1.18.0
crypto: ^3.0.6
dio: ^5.8.0+1
dotted_border: ^3.1.0
dio: ^5.8.0+1
email_validator: ^3.0.0
ente_accounts:
path: ../../packages/accounts
@@ -25,23 +22,19 @@ dependencies:
url: https://github.com/ente-io/ente_crypto_dart.git
ente_events:
path: ../../packages/events
ente_legacy:
path: ../../packages/legacy
ente_lock_screen:
path: ../../packages/lock_screen
ente_logging:
path: ../../packages/logging
ente_network:
path: ../../packages/network
ente_sharing:
path: ../../packages/sharing
ente_strings:
path: ../../packages/strings
ente_ui:
path: ../../packages/ui
ente_utils:
path: ../../packages/utils
event_bus: ^2.0.1
event_bus: ^2.0.1
expandable: ^5.0.1
fast_base58: ^0.2.1
file_picker: ^10.2.0
@@ -53,9 +46,8 @@ dependencies:
url: https://github.com/eaceto/flutter_local_authentication
ref: 1ac346a04592a05fd75acccf2e01fa3c7e955d96
flutter_localizations:
sdk: flutter
flutter_svg: ^2.2.1
intl: ^0.20.2
sdk: flutter
http: ^1.4.0
io: ^1.0.5
listen_sharing_intent: ^1.9.2
logging: ^1.3.0
@@ -64,12 +56,12 @@ dependencies:
path: ^1.9.0
path_provider: ^2.1.5
shared_preferences: ^2.5.3
sqflite: ^2.4.1
sqflite: ^2.4.1
styled_text: ^8.1.0
tray_manager: ^0.5.0
tuple: ^2.0.2
url_launcher: ^6.3.2
uuid: ^4.5.1
window_manager: ^0.5.1
uuid: ^4.5.1
window_manager: ^0.5.0
dev_dependencies:
flutter_launcher_icons: ^0.14.3
@@ -83,7 +75,6 @@ flutter:
assets:
- assets/
- assets/icons/
fonts:
- family: Inter

View File

@@ -1,4 +1,4 @@
# melos_managed_dependency_overrides: ente_accounts,ente_base,ente_configuration,ente_events,ente_lock_screen,ente_logging,ente_network,ente_strings,ente_ui,ente_utils,ente_sharing,ente_legacy
# melos_managed_dependency_overrides: ente_accounts,ente_base,ente_configuration,ente_events,ente_lock_screen,ente_logging,ente_network,ente_strings,ente_ui,ente_utils
dependency_overrides:
ente_accounts:
path: ../../packages/accounts
@@ -8,16 +8,12 @@ dependency_overrides:
path: ../../packages/configuration
ente_events:
path: ../../packages/events
ente_legacy:
path: ../../packages/legacy
ente_lock_screen:
path: ../../packages/lock_screen
ente_logging:
path: ../../packages/logging
ente_network:
path: ../../packages/network
ente_sharing:
path: ../../packages/sharing
ente_strings:
path: ../../packages/strings
ente_ui:

View File

@@ -1 +0,0 @@
CLAUDE.md

View File

@@ -1,213 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude, Codex, and any other agent when working with code in this repository.
## Project Philosophy
Ente is focused on privacy, transparency and trust. It's a fully open-source, end-to-end encrypted platform for storing data in the cloud. When contributing, always prioritize:
- User privacy and data security
- End-to-end encryption integrity
- Transparent, auditable code
- Zero-knowledge architecture principles
## Monorepo Context
This is the Ente Photos mobile app within the Ente monorepo. The monorepo contains:
- Mobile apps (Photos, Auth, Locker) at `mobile/apps/`
- Shared packages at `mobile/packages/`
- Web, desktop, CLI, and server components in parent directories
### Package Architecture
The Photos app uses two types of packages:
- **Shared packages** (`../../packages/`): Common code shared across multiple Ente apps (Photos, Auth, Locker)
- **Photos-specific plugins** (`./plugins/`): Custom Flutter plugins specific to Photos app for separation and testability
## Commit & PR Guidelines
⚠️ **CRITICAL: From the default template, use ONLY: Co-Authored-By: Claude <noreply@anthropic.com>** ⚠️
### Pre-commit/PR Checklist (RUN BEFORE EVERY COMMIT OR PR!)
**CRITICAL: CI will fail if ANY of these checks fail. Run ALL commands and ensure they ALL pass.**
```bash
# 1. Format Dart code
dart format .
# 2. Analyze flutter code for errors and warnings
flutter analyze
```
**Why CI might fail even after running these:**
- Skipping any command above
- Assuming auto-fix tools handle everything (they don't)
- Not fixing warnings that flutter reports
- Making changes after running the checks
### Commit & PR Message Rules
**These rules apply to BOTH commit messages AND pull request descriptions**
- Keep messages CONCISE (no walls of text)
- Subject line under 72 chars (no body text unless critical)
- NO emojis
- NO promotional text or links (except Co-Authored-By line)
### Additional Guidelines
- Check `git status` before committing to avoid adding temporary/binary files
- Never commit to main branch
- All CI checks must pass - run the checklist commands above before committing or creating PR
## Development Commands
### Using Melos (Monorepo Management)
```bash
# From mobile/ directory - bootstrap all packages
melos bootstrap
# Run Photos app specifically
melos run:photos:apk
# Build Photos APK
melos build:photos:apk
# Clean Photos app
melos clean:photos
```
### Direct Flutter Commands
```bash
# Development run with environment variables
./run.sh # Uses .env file with --flavor dev
# Development run without env file
flutter run -t lib/main.dart --flavor independent
# Build release APK
flutter build apk --release --flavor independent
# iOS build
cd ios && pod install && cd ..
flutter build ios
```
### Code Quality
```bash
# Static analysis and linting
flutter analyze .
# Run tests
flutter test
```
## Architecture Overview
### Service-Oriented Architecture
The app uses a service layer pattern with 28+ specialized services:
- **collections_service.dart**: Album and collection management
- **search_service.dart**: Search functionality with ML support
- **smart_memories_service.dart**: AI-powered memory curation
- **sync_service.dart**: Local/remote synchronization
- **Machine Learning Services**: Face recognition, semantic search, similar images
### Key Patterns
- **Service Locator**: Dependency injection via `lib/service_locator.dart`
- **Event Bus**: Loose coupling via `lib/core/event_bus.dart`
- **Repository Pattern**: Database abstraction in `lib/db/`
- **Rust Integration**: Performance-critical operations via Flutter Rust Bridge
### Security Architecture
- End-to-end encryption with `ente_crypto` package
- BIP39 mnemonic-based key generation (24 words)
- Secure storage using platform-specific implementations
- App lock and privacy screen features
## Project Structure
```
lib/
├── core/ # Configuration, constants, networking
├── services/ # Business logic (28+ services)
├── ui/ # UI components (18 subdirectories)
├── models/ # Data models (17 subdirectories)
├── db/ # SQLite database layer
├── utils/ # Utilities and helpers
├── gateways/ # API gateway interfaces
├── events/ # Event system
├── l10n/ # Localization files (intl_*.arb)
└── generated/ # Auto-generated code including localizations
```
## Localization (Flutter)
- Add new strings to `lib/l10n/intl_en.arb` (English base file)
- Use `AppLocalizations` to access localized strings in code
- Example: `AppLocalizations.of(context).yourStringKey`
- Run code generation after adding new strings: `flutter pub get`
- Translations managed via Crowdin for other languages
## Key Dependencies
- **Flutter 3.32.8** with Dart SDK >=3.3.0 <4.0.0
- **Media**: `photo_manager`, `video_editor`, `ffmpeg_kit_flutter`
- **Storage**: `sqlite_async`, `flutter_secure_storage`
- **ML/AI**: Custom ONNX runtime, `ml_linalg`
- **Rust**: Flutter Rust Bridge for performance
## Development Setup Requirements
1. Install Flutter v3.32.8 and Rust
2. Install Flutter Rust Bridge: `cargo install flutter_rust_bridge_codegen`
3. Generate Rust bindings: `flutter_rust_bridge_codegen generate`
4. Update submodules: `git submodule update --init --recursive`
5. Enable git hooks: `git config core.hooksPath hooks`
## Critical Coding Requirements
### 1. Code Quality - MANDATORY
**Every code change MUST pass `dart format .` and `flutter analyze` with zero issues**
- Run `dart format .` first to format all Dart code
- Run `flutter analyze` after EVERY code modification
- Resolve ALL issues (info, warning, error) - no exceptions
- The codebase has zero issues by default, so any issue is from your changes
- DO NOT commit or consider work complete until both commands pass cleanly
### 2. Component Reuse - MANDATORY
**Always try to reuse existing components**
- Use a subagent to search for existing components before creating new ones
- Only create new components if none exist that meet the requirements
- Check both UI components in `lib/ui/` and shared components in `../../packages/`
### 3. Design System - MANDATORY
**Never hardcode colors or text styles**
- Always use the Ente design system for colors and typography
- Use a subagent to find the appropriate design tokens
- Access colors via theme: `getEnteColorScheme(context)`
- Access text styles via theme: `getEnteTextTheme(context)`
- Call above theme getters only at the top of (`build`) methods and re-use them throughout the component
- If you MUST use custom colors/styles (extremely rare), explicitly inform the user with a clear warning
### 4. Documentation Sync - MANDATORY
**Keep spec documents synchronized with code changes**
- When modifying code, also update any associated spec documents
- Check for related spec files in `docs/` or project directories
- Ensure documentation reflects the current implementation
- Update examples in specs if behavior changes
### 5. Database Methods - BEST PRACTICE
**Prioritize readability in database methods**
- For small result sets (e.g., 1-2 stale entries), prefer filtering in Dart for cleaner, more readable code
- For large datasets, use SQL WHERE clauses for performance - they're much more efficient in SQLite
## Important Notes
- Large service files (some 70k+ lines) - consider file context when editing
- 400+ dependencies - check existing libraries before adding new ones
- When adding functionality, check both `../../packages/` for shared code and `./plugins/` for Photos-specific plugins
- Performance-critical paths use Rust integration
- Always follow existing code conventions and patterns in neighboring files
# Individual Preferences
- @~/.claude/ente-photos-instructions.md

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,3 @@
description: This file stores settings for Dart & Flutter DevTools.
=description: This file stores settings for Dart & Flutter DevTools.
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
extensions:

View File

@@ -29,6 +29,10 @@ class LRUMap<K, V> {
}
}
bool containsKey(K key) {
return _map.containsKey(key);
}
void remove(K key) {
_map.remove(key);
}

View File

@@ -1,39 +0,0 @@
import 'dart:typed_data';
import 'package:photos/core/cache/lru_map.dart';
import 'package:photos/core/constants.dart';
import 'package:photos/models/file/file.dart';
class ThumbnailInMemoryLruCache {
static final LRUMap<String, Uint8List?> _map = LRUMap(1000);
static Uint8List? get(EnteFile enteFile, [int? size]) {
return _map.get(
enteFile.cacheKey() +
"_" +
(size != null ? size.toString() : thumbnailLargeSize.toString()),
);
}
static void put(
EnteFile enteFile,
Uint8List? imageData, [
int? size,
]) {
_map.put(
enteFile.cacheKey() +
"_" +
(size != null ? size.toString() : thumbnailLargeSize.toString()),
imageData,
);
}
static void clearCache(EnteFile enteFile) {
_map.remove(
enteFile.cacheKey() + "_" + thumbnailLargeSize.toString(),
);
_map.remove(
enteFile.cacheKey() + "_" + thumbnailSmallSize.toString(),
);
}
}

View File

@@ -11,11 +11,9 @@ import 'package:path_provider/path_provider.dart';
import 'package:photos/core/constants.dart';
import 'package:photos/core/error-reporting/super_logging.dart';
import 'package:photos/core/event_bus.dart';
import 'package:photos/db/collections_db.dart';
import 'package:photos/db/files_db.dart';
import "package:photos/db/memories_db.dart";
import "package:photos/db/ml/db.dart";
import 'package:photos/db/trash_db.dart';
import 'package:photos/db/upload_locks_db.dart';
import "package:photos/events/endpoint_updated_event.dart";
import 'package:photos/events/signed_in_event.dart';
@@ -23,6 +21,8 @@ import 'package:photos/events/user_logged_out_event.dart';
import 'package:photos/models/api/user/key_attributes.dart';
import 'package:photos/models/api/user/key_gen_result.dart';
import 'package:photos/models/api/user/private_key_attributes.dart';
import 'package:photos/module/upload/service/file_uploader.dart';
import "package:photos/service_locator.dart";
import 'package:photos/services/collections_service.dart';
import 'package:photos/services/favorites_service.dart';
import "package:photos/services/home_widget_service.dart";
@@ -31,7 +31,6 @@ import "package:photos/services/machine_learning/face_ml/person/person_service.d
import "package:photos/services/machine_learning/similar_images_service.dart";
import 'package:photos/services/search_service.dart';
import 'package:photos/services/sync/sync_service.dart';
import 'package:photos/utils/file_uploader.dart';
import "package:photos/utils/lock_screen_settings.dart";
import 'package:photos/utils/validator_util.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -194,14 +193,13 @@ class Configuration {
_cachedToken = null;
_secretKey = null;
await FilesDB.instance.clearTable();
await CollectionsDB.instance.clearTable();
// await CollectionsDB.instance.clearTable();
await MemoriesDB.instance.clearTable();
await MLDataDB.instance.clearTable();
await remoteDB.clearAllTables();
await SimilarImagesService.instance.clearCache();
await UploadLocksDB.instance.clearTable();
await IgnoredFilesService.instance.reset();
await TrashDB.instance.clearTable();
unawaited(HomeWidgetService.instance.clearWidget(autoLogout));
if (!autoLogout) {
// Following services won't be initialized if it's the case of autoLogout

View File

@@ -1,10 +1,12 @@
import "package:flutter/foundation.dart";
const int thumbnailSmallSize = 256;
const int thumbnailQuality = 50;
const int thumbnailLargeSize = 512;
const int compressedThumbnailResolution = 1080;
const int thumbnailDataLimit = 100 * 1024;
// thumbnailSmallSize Thumbnail sizes in pixels 256px
const int thumbnailSmall256 = 256;
// thumbnailMediumSize Thumbnail sizes in pixels 512px
const int thumbnailLarge512 = 512; // 512px
const int compressThumb1080 = 1080;
const int thumbnailDataMaxSize = 100 * 1024;
const String sentryDSN =
"https://2235e5c99219488ea93da34b9ac1cb68@sentry.ente.io/4";
const String sentryDebugDSN =
@@ -109,4 +111,4 @@ final tempDirCleanUpInterval = kDebugMode
const kFilterChipHeight = 32.0;
const kMaxAppbarFilters = 14;
const kLivePhotoHashSeparator = ':';
const kHashSeprator = ':';

View File

@@ -5,10 +5,9 @@ import 'dart:io';
import "package:dio/dio.dart";
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:http/http.dart' as http;
import 'package:intl/intl.dart';
import 'package:log_viewer/log_viewer.dart';
import 'package:logging/logging.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:path/path.dart';
@@ -189,15 +188,6 @@ class SuperLogging {
Logger.root.level = kDebugMode ? Level.ALL : Level.INFO;
Logger.root.onRecord.listen(onLogRecord);
if (_preferences.getBool("enable_db_logging") ?? kDebugMode) {
try {
await LogViewer.initialize(prefix: appConfig.prefix);
$.info("Log viewer initialized successfully");
} catch (e) {
$.warning("Failed to initialize log viewer: $e");
}
}
if (isFDroidClient) {
assert(
sentryIsEnabled == false,
@@ -465,15 +455,4 @@ class SuperLogging {
final pkgName = (await PackageInfo.fromPlatform()).packageName;
return pkgName.startsWith("io.ente.photos.fdroid");
}
/// Show the log viewer page
/// This is the main integration point for accessing the log viewer
static void showLogViewer(BuildContext context) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const LogViewerPage(),
),
);
}
}

View File

@@ -1,13 +1,13 @@
// Common runtime exceptions that can occur during normal app operation.
// These are recoverable conditions that should be caught and handled.
/// Common runtime exceptions that can occur during normal app operation.
/// These are recoverable conditions that should be caught and handled.
class WidgetUnmountedException implements Exception {
final String? message;
WidgetUnmountedException([this.message]);
@override
String toString() => message != null
? 'WidgetUnmountedException: $message'
String toString() => message != null
? 'WidgetUnmountedException: $message'
: 'WidgetUnmountedException';
}
}

View File

@@ -1,322 +0,0 @@
import 'dart:convert';
import 'dart:io';
import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';
import "package:photos/models/api/collection/public_url.dart";
import "package:photos/models/api/collection/user.dart";
import 'package:photos/models/collection/collection.dart';
import 'package:sqflite/sqflite.dart';
import 'package:sqflite_migration/sqflite_migration.dart';
class CollectionsDB {
static const _databaseName = "ente.collections.db";
static const table = 'collections';
static const tempTable = 'temp_collections';
static const _sqlBoolTrue = 1;
static const _sqlBoolFalse = 0;
static const columnID = 'collection_id';
static const columnOwner = 'owner';
static const columnEncryptedKey = 'encrypted_key';
static const columnKeyDecryptionNonce = 'key_decryption_nonce';
static const columnName = 'name';
static const columnEncryptedName = 'encrypted_name';
static const columnNameDecryptionNonce = 'name_decryption_nonce';
static const columnType = 'type';
static const columnEncryptedPath = 'encrypted_path';
static const columnPathDecryptionNonce = 'path_decryption_nonce';
static const columnVersion = 'version';
static const columnSharees = 'sharees';
static const columnPublicURLs = 'public_urls';
// MMD -> Magic Metadata
static const columnMMdEncodedJson = 'mmd_encoded_json';
static const columnMMdVersion = 'mmd_ver';
static const columnPubMMdEncodedJson = 'pub_mmd_encoded_json';
static const columnPubMMdVersion = 'pub_mmd_ver';
static const columnSharedMMdJson = 'shared_mmd_json';
static const columnSharedMMdVersion = 'shared_mmd_ver';
static const columnUpdationTime = 'updation_time';
static const columnIsDeleted = 'is_deleted';
static final intitialScript = [...createTable(table)];
static final migrationScripts = [
...alterNameToAllowNULL(),
...addEncryptedName(),
...addVersion(),
...addIsDeleted(),
...addPublicURLs(),
...addPrivateMetadata(),
...addPublicMetadata(),
...addShareeMetadata(),
];
final dbConfig = MigrationConfig(
initializationScript: intitialScript,
migrationScripts: migrationScripts,
);
CollectionsDB._privateConstructor();
static final CollectionsDB instance = CollectionsDB._privateConstructor();
static Future<Database>? _dbFuture;
Future<Database> get database async {
_dbFuture ??= _initDatabase();
return _dbFuture!;
}
Future<Database> _initDatabase() async {
final Directory documentsDirectory =
await getApplicationDocumentsDirectory();
final String path = join(documentsDirectory.path, _databaseName);
return await openDatabaseWithMigration(path, dbConfig);
}
Future<void> clearTable() async {
final db = await instance.database;
await db.delete(table);
}
static List<String> createTable(String tableName) {
return [
'''
CREATE TABLE $tableName (
$columnID INTEGER PRIMARY KEY NOT NULL,
$columnOwner TEXT NOT NULL,
$columnEncryptedKey TEXT NOT NULL,
$columnKeyDecryptionNonce TEXT,
$columnName TEXT,
$columnType TEXT NOT NULL,
$columnEncryptedPath TEXT,
$columnPathDecryptionNonce TEXT,
$columnSharees TEXT,
$columnUpdationTime TEXT NOT NULL
);
'''
];
}
static List<String> alterNameToAllowNULL() {
return [
...createTable(tempTable),
'''
INSERT INTO $tempTable
SELECT *
FROM $table;
DROP TABLE $table;
ALTER TABLE $tempTable
RENAME TO $table;
'''
];
}
static List<String> addEncryptedName() {
return [
'''
ALTER TABLE $table
ADD COLUMN $columnEncryptedName TEXT;
''',
'''ALTER TABLE $table
ADD COLUMN $columnNameDecryptionNonce TEXT;
'''
];
}
static List<String> addVersion() {
return [
'''
ALTER TABLE $table
ADD COLUMN $columnVersion INTEGER DEFAULT 0;
'''
];
}
static List<String> addIsDeleted() {
return [
'''
ALTER TABLE $table
ADD COLUMN $columnIsDeleted INTEGER DEFAULT $_sqlBoolFalse;
'''
];
}
static List<String> addPublicURLs() {
return [
'''
ALTER TABLE $table
ADD COLUMN $columnPublicURLs TEXT;
'''
];
}
static List<String> addPrivateMetadata() {
return [
'''
ALTER TABLE $table ADD COLUMN $columnMMdEncodedJson TEXT DEFAULT '{}';
''',
'''
ALTER TABLE $table ADD COLUMN $columnMMdVersion INTEGER DEFAULT 0;
'''
];
}
static List<String> addPublicMetadata() {
return [
'''
ALTER TABLE $table ADD COLUMN $columnPubMMdEncodedJson TEXT DEFAULT '
{}';
''',
'''
ALTER TABLE $table ADD COLUMN $columnPubMMdVersion INTEGER DEFAULT 0;
'''
];
}
static List<String> addShareeMetadata() {
return [
'''
ALTER TABLE $table ADD COLUMN $columnSharedMMdJson TEXT DEFAULT '
{}';
''',
'''
ALTER TABLE $table ADD COLUMN $columnSharedMMdVersion INTEGER DEFAULT 0;
'''
];
}
Future<void> insert(List<Collection> collections) async {
final db = await instance.database;
var batch = db.batch();
int batchCounter = 0;
for (final collection in collections) {
if (batchCounter == 400) {
await batch.commit(noResult: true);
batch = db.batch();
batchCounter = 0;
}
batch.insert(
table,
_getRowForCollection(collection),
conflictAlgorithm: ConflictAlgorithm.replace,
);
batchCounter++;
}
await batch.commit(noResult: true);
}
Future<List<Collection>> getAllCollections() async {
final db = await instance.database;
final rows = await db.query(table);
final collections = <Collection>[];
for (final row in rows) {
collections.add(_convertToCollection(row));
}
return collections;
}
// getActiveCollectionIDsAndUpdationTime returns map of collectionID to
// updationTime for non-deleted collections
Future<Map<int, int>> getActiveIDsAndRemoteUpdateTime() async {
final db = await instance.database;
final rows = await db.query(
table,
where: '($columnIsDeleted = ? OR $columnIsDeleted IS NULL)',
whereArgs: [_sqlBoolFalse],
columns: [columnID, columnUpdationTime],
);
final collectionIDsAndUpdationTime = <int, int>{};
for (final row in rows) {
collectionIDsAndUpdationTime[row[columnID] as int] =
int.parse(row[columnUpdationTime] as String);
}
return collectionIDsAndUpdationTime;
}
Future<int> deleteCollection(int collectionID) async {
final db = await instance.database;
return db.delete(
table,
where: '$columnID = ?',
whereArgs: [collectionID],
);
}
Map<String, dynamic> _getRowForCollection(Collection collection) {
final row = <String, dynamic>{};
row[columnID] = collection.id;
row[columnOwner] = collection.owner.toJson();
row[columnEncryptedKey] = collection.encryptedKey;
row[columnKeyDecryptionNonce] = collection.keyDecryptionNonce;
// ignore: deprecated_member_use_from_same_package
row[columnName] = collection.name;
row[columnEncryptedName] = collection.encryptedName;
row[columnNameDecryptionNonce] = collection.nameDecryptionNonce;
row[columnType] = typeToString(collection.type);
row[columnEncryptedPath] = collection.attributes.encryptedPath;
row[columnPathDecryptionNonce] = collection.attributes.pathDecryptionNonce;
row[columnVersion] = collection.attributes.version;
row[columnSharees] =
json.encode(collection.sharees.map((x) => x.toMap()).toList());
row[columnPublicURLs] =
json.encode(collection.publicURLs.map((x) => x.toMap()).toList());
row[columnUpdationTime] = collection.updationTime;
if (collection.isDeleted) {
row[columnIsDeleted] = _sqlBoolTrue;
} else {
row[columnIsDeleted] = _sqlBoolFalse;
}
row[columnMMdVersion] = collection.mMdVersion;
row[columnMMdEncodedJson] = collection.mMdEncodedJson ?? '{}';
row[columnPubMMdVersion] = collection.mMbPubVersion;
row[columnPubMMdEncodedJson] = collection.mMdPubEncodedJson ?? '{}';
row[columnSharedMMdVersion] = collection.sharedMmdVersion;
row[columnSharedMMdJson] = collection.sharedMmdJson ?? '{}';
return row;
}
Collection _convertToCollection(Map<String, dynamic> row) {
final Collection result = Collection(
row[columnID],
User.fromJson(row[columnOwner]),
row[columnEncryptedKey],
row[columnKeyDecryptionNonce],
row[columnName],
row[columnEncryptedName],
row[columnNameDecryptionNonce],
typeFromString(row[columnType]),
CollectionAttributes(
encryptedPath: row[columnEncryptedPath],
pathDecryptionNonce: row[columnPathDecryptionNonce],
version: row[columnVersion],
),
List<User>.from(
(json.decode(row[columnSharees]) as List).map((x) => User.fromMap(x)),
),
row[columnPublicURLs] == null
? []
: List<PublicURL>.from(
(json.decode(row[columnPublicURLs]) as List)
.map((x) => PublicURL.fromMap(x)),
),
int.parse(row[columnUpdationTime]),
// default to False is columnIsDeleted is not set
isDeleted: (row[columnIsDeleted] ?? _sqlBoolFalse) == _sqlBoolTrue,
);
result.mMdVersion = row[columnMMdVersion] ?? 0;
result.mMdEncodedJson = row[columnMMdEncodedJson] ?? '{}';
result.mMbPubVersion = row[columnPubMMdVersion] ?? 0;
result.mMdPubEncodedJson = row[columnPubMMdEncodedJson] ?? '{}';
result.sharedMmdVersion = row[columnSharedMMdVersion] ?? 0;
result.sharedMmdJson = row[columnSharedMMdJson] ?? '{}';
return result;
}
}

View File

@@ -2,9 +2,9 @@ import "package:flutter/foundation.dart";
import "package:sqlite_async/sqlite_async.dart";
mixin SqlDbBase {
static const _params = {};
static final _params = {};
static String getParams(int count) {
String getParams(int count) {
if (!_params.containsKey(count)) {
final params = List.generate(count, (_) => "?").join(", ");
_params[count] = params;
@@ -14,9 +14,13 @@ mixin SqlDbBase {
Future<void> migrate(
SqliteDatabase database,
List<String> migrationScripts,
) async {
List<String> migrationScripts, {
bool onForeignKey = false,
}) async {
final result = await database.execute('PRAGMA user_version');
if (onForeignKey) {
await database.execute("PRAGMA foreign_keys = ON");
}
final currentVersion = result[0]['user_version'] as int;
final toVersion = migrationScripts.length;

View File

@@ -1,492 +0,0 @@
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:logging/logging.dart';
import 'package:photo_manager/photo_manager.dart';
import 'package:photos/db/files_db.dart';
import 'package:photos/models/backup_status.dart';
import 'package:photos/models/device_collection.dart';
import 'package:photos/models/file/file.dart';
import 'package:photos/models/file_load_result.dart';
import 'package:photos/models/upload_strategy.dart';
import "package:photos/services/sync/import/model.dart";
import 'package:sqflite/sqlite_api.dart';
import 'package:tuple/tuple.dart';
extension DeviceFiles on FilesDB {
static final Logger _logger = Logger("DeviceFilesDB");
static const _sqlBoolTrue = 1;
static const _sqlBoolFalse = 0;
Future<void> insertPathIDToLocalIDMapping(
Map<String, Set<String>> mappingToAdd, {
ConflictAlgorithm conflictAlgorithm = ConflictAlgorithm.ignore,
}) async {
debugPrint("Inserting missing PathIDToLocalIDMapping");
final parameterSets = <List<Object?>>[];
int batchCounter = 0;
for (MapEntry e in mappingToAdd.entries) {
final String pathID = e.key;
for (String localID in e.value) {
parameterSets.add([localID, pathID]);
batchCounter++;
if (batchCounter == 400) {
await _insertBatch(parameterSets, conflictAlgorithm);
parameterSets.clear();
batchCounter = 0;
}
}
}
await _insertBatch(parameterSets, conflictAlgorithm);
parameterSets.clear();
batchCounter = 0;
}
Future<void> deletePathIDToLocalIDMapping(
Map<String, Set<String>> mappingsToRemove,
) async {
debugPrint("removing PathIDToLocalIDMapping");
final parameterSets = <List<Object?>>[];
int batchCounter = 0;
for (MapEntry e in mappingsToRemove.entries) {
final String pathID = e.key;
for (String localID in e.value) {
parameterSets.add([localID, pathID]);
batchCounter++;
if (batchCounter == 400) {
await _deleteBatch(parameterSets);
parameterSets.clear();
batchCounter = 0;
}
}
}
await _deleteBatch(parameterSets);
parameterSets.clear();
batchCounter = 0;
}
Future<Map<String, int>> getDevicePathIDToImportedFileCount() async {
try {
final db = await sqliteAsyncDB;
final rows = await db.getAll(
'''
SELECT count(*) as count, path_id
FROM device_files
GROUP BY path_id
''',
);
final result = <String, int>{};
for (final row in rows) {
result[row['path_id'] as String] = row["count"] as int;
}
return result;
} catch (e) {
_logger.severe("failed to getDevicePathIDToImportedFileCount", e);
rethrow;
}
}
Future<Map<String, Set<String>>> getDevicePathIDToLocalIDMap() async {
try {
final db = await sqliteAsyncDB;
final rows = await db.getAll(
''' SELECT id, path_id FROM device_files; ''',
);
final result = <String, Set<String>>{};
for (final row in rows) {
final String pathID = row['path_id'] as String;
if (!result.containsKey(pathID)) {
result[pathID] = <String>{};
}
result[pathID]!.add(row['id'] as String);
}
return result;
} catch (e) {
_logger.severe("failed to getDevicePathIDToLocalIDMap", e);
rethrow;
}
}
Future<Set<String>> getDevicePathIDs() async {
final db = await sqliteAsyncDB;
final rows = await db.getAll(
'''
SELECT id FROM device_collections
''',
);
final Set<String> result = <String>{};
for (final row in rows) {
result.add(row['id'] as String);
}
return result;
}
Future<void> insertLocalAssets(
List<LocalPathAsset> localPathAssets, {
bool shouldAutoBackup = false,
}) async {
final db = await sqliteAsyncDB;
final Map<String, Set<String>> pathIDToLocalIDsMap = {};
try {
final Set<String> existingPathIds = await getDevicePathIDs();
final parameterSetsForUpdate = <List<Object?>>[];
final parameterSetsForInsert = <List<Object?>>[];
for (LocalPathAsset localPathAsset in localPathAssets) {
if (localPathAsset.localIDs.isNotEmpty) {
pathIDToLocalIDsMap[localPathAsset.pathID] = localPathAsset.localIDs;
}
if (existingPathIds.contains(localPathAsset.pathID)) {
parameterSetsForUpdate
.add([localPathAsset.pathName, localPathAsset.pathID]);
} else if (localPathAsset.localIDs.isNotEmpty) {
parameterSetsForInsert.add([
localPathAsset.pathID,
localPathAsset.pathName,
shouldAutoBackup ? _sqlBoolTrue : _sqlBoolFalse,
]);
}
}
await db.executeBatch(
'''
INSERT OR IGNORE INTO device_collections (id, name, should_backup) VALUES (?, ?, ?);
''',
parameterSetsForInsert,
);
await db.executeBatch(
'''
UPDATE device_collections SET name = ? WHERE id = ?;
''',
parameterSetsForUpdate,
);
// add the mappings for localIDs
if (pathIDToLocalIDsMap.isNotEmpty) {
await insertPathIDToLocalIDMapping(pathIDToLocalIDsMap);
}
} catch (e) {
_logger.severe("failed to save path names", e);
rethrow;
}
}
Future<bool> updateDeviceCoverWithCount(
List<Tuple2<AssetPathEntity, String>> devicePathInfo, {
bool shouldBackup = false,
}) async {
bool hasUpdated = false;
try {
final db = await sqliteAsyncDB;
final Set<String> existingPathIds = await getDevicePathIDs();
for (Tuple2<AssetPathEntity, String> tup in devicePathInfo) {
final AssetPathEntity pathEntity = tup.item1;
final assetCount = await pathEntity.assetCountAsync;
final String localID = tup.item2;
final bool shouldUpdate = existingPathIds.contains(pathEntity.id);
if (shouldUpdate) {
final rowUpdated = await db.writeTransaction((tx) async {
await tx.execute(
"UPDATE device_collections SET name = ?, cover_id = ?, count"
" = ? where id = ? AND (name != ? OR cover_id != ? OR count != ?)",
[
pathEntity.name,
localID,
assetCount,
pathEntity.id,
pathEntity.name,
localID,
assetCount,
],
);
final result = await tx.get("SELECT changes();");
return result["changes()"] as int;
});
if (rowUpdated > 0) {
_logger.info("Updated $rowUpdated rows for ${pathEntity.name}");
hasUpdated = true;
}
} else {
hasUpdated = true;
await db.execute(
'''
INSERT INTO device_collections (id, name, count, cover_id, should_backup)
VALUES (?, ?, ?, ?, ?);
''',
[
pathEntity.id,
pathEntity.name,
assetCount,
localID,
shouldBackup ? _sqlBoolTrue : _sqlBoolFalse,
],
);
}
}
// delete existing pathIDs which are missing on device
existingPathIds.removeAll(devicePathInfo.map((e) => e.item1.id).toSet());
if (existingPathIds.isNotEmpty) {
hasUpdated = true;
_logger.info(
'Deleting non-backed up pathIds from local '
'$existingPathIds',
);
for (String pathID in existingPathIds) {
// do not delete device collection entries for paths which are
// marked for backup. This is to handle "Free up space"
// feature, where we delete files which are backed up. Deleting such
// entries here result in us losing out on the information that
// those folders were marked for automatic backup.
await db.execute(
'''
DELETE FROM device_collections WHERE id = ? AND should_backup = $_sqlBoolFalse;
''',
[pathID],
);
await db.execute(
'''
DELETE FROM device_files WHERE path_id = ?;
''',
[pathID],
);
}
}
return hasUpdated;
} catch (e) {
_logger.severe("failed to save path names", e);
rethrow;
}
}
// getDeviceSyncCollectionIDs returns the collectionIDs for the
// deviceCollections which are marked for auto-backup
Future<Set<int>> getDeviceSyncCollectionIDs() async {
final db = await sqliteAsyncDB;
final rows = await db.getAll(
'''
SELECT collection_id FROM device_collections where should_backup =
$_sqlBoolTrue
and collection_id != -1;
''',
);
final Set<int> result = <int>{};
for (final row in rows) {
result.add(row['collection_id'] as int);
}
return result;
}
Future<void> updateDevicePathSyncStatus(
Map<String, bool> syncStatus,
) async {
final db = await sqliteAsyncDB;
int batchCounter = 0;
final parameterSets = <List<Object?>>[];
for (MapEntry e in syncStatus.entries) {
final String pathID = e.key;
parameterSets.add([e.value ? _sqlBoolTrue : _sqlBoolFalse, pathID]);
batchCounter++;
if (batchCounter == 400) {
await db.executeBatch(
'''
UPDATE device_collections SET should_backup = ? WHERE id = ?;
''',
parameterSets,
);
parameterSets.clear();
batchCounter = 0;
}
}
await db.executeBatch(
'''
UPDATE device_collections SET should_backup = ? WHERE id = ?;
''',
parameterSets,
);
}
Future<void> updateDeviceCollection(
String pathID,
int collectionID,
) async {
final db = await sqliteAsyncDB;
await db.execute(
'''
UPDATE device_collections SET collection_id = ? WHERE id = ?;
''',
[collectionID, pathID],
);
return;
}
Future<FileLoadResult> getFilesInDeviceCollection(
DeviceCollection deviceCollection,
int? ownerID,
int startTime,
int endTime, {
int? limit,
bool? asc,
}) async {
final db = await sqliteAsyncDB;
final order = (asc ?? false ? 'ASC' : 'DESC');
final String rawQuery = '''
SELECT *
FROM ${FilesDB.filesTable}
WHERE ${FilesDB.columnLocalID} IS NOT NULL AND
${FilesDB.columnCreationTime} >= $startTime AND
${FilesDB.columnCreationTime} <= $endTime AND
(${FilesDB.columnOwnerID} IS NULL OR ${FilesDB.columnOwnerID} =
$ownerID ) AND
${FilesDB.columnLocalID} IN
(SELECT id FROM device_files where path_id = '${deviceCollection.id}' )
ORDER BY ${FilesDB.columnCreationTime} $order , ${FilesDB.columnModificationTime} $order
''' +
(limit != null ? ' limit $limit;' : ';');
final results = await db.getAll(rawQuery);
final files = convertToFiles(results);
final dedupe = deduplicateByLocalID(files);
return FileLoadResult(dedupe, files.length == limit);
}
Future<BackedUpFileIDs> getBackedUpForDeviceCollection(
String pathID,
int ownerID,
) async {
final db = await sqliteAsyncDB;
const String rawQuery = '''
SELECT ${FilesDB.columnLocalID}, ${FilesDB.columnUploadedFileID},
${FilesDB.columnFileSize}
FROM ${FilesDB.filesTable}
WHERE ${FilesDB.columnLocalID} IS NOT NULL AND
(${FilesDB.columnOwnerID} IS NULL OR ${FilesDB.columnOwnerID} = ?)
AND (${FilesDB.columnUploadedFileID} IS NOT NULL AND ${FilesDB.columnUploadedFileID} IS NOT -1)
AND
${FilesDB.columnLocalID} IN
(SELECT id FROM device_files where path_id = ?)
''';
final results = await db.getAll(rawQuery, [ownerID, pathID]);
final localIDs = <String>{};
final uploadedIDs = <int>{};
int localSize = 0;
for (final result in results) {
final String localID = result[FilesDB.columnLocalID] as String;
final int? fileSize = result[FilesDB.columnFileSize] as int?;
if (!localIDs.contains(localID) && fileSize != null) {
localSize += fileSize;
}
localIDs.add(localID);
uploadedIDs.add(result[FilesDB.columnUploadedFileID] as int);
}
return BackedUpFileIDs(localIDs.toList(), uploadedIDs.toList(), localSize);
}
Future<List<DeviceCollection>> getDeviceCollections({
bool includeCoverThumbnail = false,
}) async {
debugPrint(
"Fetching DeviceCollections From DB with thumbnail = "
"$includeCoverThumbnail",
);
try {
final db = await sqliteAsyncDB;
final coverFiles = <EnteFile>[];
if (includeCoverThumbnail) {
final fileRows = await db.getAll(
'''SELECT * FROM FILES where local_id in (select cover_id from device_collections) group by local_id;
''',
);
final files = convertToFiles(fileRows);
coverFiles.addAll(files);
}
final deviceCollectionRows = await db.getAll(
'''SELECT * from device_collections''',
);
final List<DeviceCollection> deviceCollections = [];
for (var row in deviceCollectionRows) {
final DeviceCollection deviceCollection = DeviceCollection(
row["id"] as String,
(row['name'] ?? '') as String,
count: row['count'] as int,
collectionID: (row["collection_id"] ?? -1) as int,
coverId: row["cover_id"] as String?,
shouldBackup: (row["should_backup"] ?? _sqlBoolFalse) == _sqlBoolTrue,
uploadStrategy: getUploadType((row["upload_strategy"] ?? 0) as int),
);
if (includeCoverThumbnail) {
deviceCollection.thumbnail = coverFiles.firstWhereOrNull(
(element) => element.localID == deviceCollection.coverId,
);
if (deviceCollection.thumbnail == null) {
final EnteFile? result =
await getDeviceCollectionThumbnail(deviceCollection.id);
if (result == null) {
_logger.info(
'Failed to find coverThumbnail for deviceFolder',
);
continue;
} else {
deviceCollection.thumbnail = result;
}
}
}
deviceCollections.add(deviceCollection);
}
if (includeCoverThumbnail) {
deviceCollections.sort(
(a, b) =>
b.thumbnail!.creationTime!.compareTo(a.thumbnail!.creationTime!),
);
}
return deviceCollections;
} catch (e) {
_logger.severe('Failed to getDeviceCollections', e);
rethrow;
}
}
Future<EnteFile?> getDeviceCollectionThumbnail(String pathID) async {
debugPrint("Call fallback method to get potential thumbnail");
final db = await sqliteAsyncDB;
final fileRows = await db.getAll(
'''SELECT * FROM FILES f JOIN device_files df on f.local_id = df.id
and df.path_id= ? order by f.creation_time DESC limit 1;
''',
[pathID],
);
final files = convertToFiles(fileRows);
if (files.isNotEmpty) {
return files.first;
} else {
return null;
}
}
Future<void> _insertBatch(
List<List<Object?>> parameterSets,
ConflictAlgorithm conflictAlgorithm,
) async {
final db = await sqliteAsyncDB;
await db.executeBatch(
'''
INSERT OR ${conflictAlgorithm.name.toUpperCase()}
INTO device_files (id, path_id) VALUES (?, ?);
''',
parameterSets,
);
}
Future<void> _deleteBatch(List<List<Object?>> parameterSets) async {
final db = await sqliteAsyncDB;
await db.executeBatch(
'''
DELETE FROM device_files WHERE id = ? AND path_id = ?;
''',
parameterSets,
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,333 @@
import "dart:io";
import "package:collection/collection.dart";
import "package:flutter/foundation.dart";
import "package:path/path.dart";
import "package:path_provider/path_provider.dart";
import "package:photo_manager/photo_manager.dart";
import "package:photos/db/common/base.dart";
import "package:photos/db/local/mappers.dart";
import "package:photos/db/local/schema.dart";
import "package:photos/log/devlog.dart";
import 'package:photos/models/file/file.dart';
import "package:photos/models/file/mapping/local_mapping.dart";
import "package:photos/models/local/local_metadata.dart";
import "package:sqlite_async/sqlite_async.dart";
class LocalDB with SqlDbBase {
static const _databaseName = "local_6.db";
static const batchInsertMaxCount = 1000;
static const _smallTableBatchInsertMaxCount = 5000;
late final SqliteDatabase _sqliteDB;
SqliteDatabase get sqliteDB => _sqliteDB;
Future<void> init() async {
devLog("LocalDB init");
final Directory documentsDirectory =
await getApplicationDocumentsDirectory();
final String path = join(documentsDirectory.path, _databaseName);
final db = SqliteDatabase(path: path);
await migrate(db, LocalDBMigration.migrationScripts, onForeignKey: true);
_sqliteDB = db;
debugPrint("LocalDB init complete $path");
}
Future<void> insertAssets(List<AssetEntity> entries) async {
if (entries.isEmpty) return;
final stopwatch = Stopwatch()..start();
await Future.forEach(entries.slices(batchInsertMaxCount), (slice) async {
final List<List<Object?>> values =
slice.map((e) => LocalDBMappers.assetsRow(e)).toList();
await _sqliteDB.executeBatch(
'INSERT INTO assets ($assetColumns) values(${getParams(16)}) ON CONFLICT(id) DO UPDATE SET $updateAssetColumns',
values,
);
});
debugPrint(
'$runtimeType insertAssets complete in ${stopwatch.elapsed.inMilliseconds}ms for ${entries.length} assets',
);
}
// Store time and location metadata inside edited_assets
Future<void> trackEdit(
String id,
int createdAt,
int modifiedAt,
double? lat,
double? lng,
) async {
final stopwatch = Stopwatch()..start();
await _sqliteDB.execute(
'INSERT INTO edited_assets (id, created_at, modified_at, latitude, longitude) VALUES (?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET created_at = ?, modified_at = ?, latitude = ?, longitude = ?',
[id, createdAt, modifiedAt, lat, lng, createdAt, modifiedAt, lat, lng],
);
debugPrint(
'$runtimeType editCopy complete in ${stopwatch.elapsed.inMilliseconds}ms for $id',
);
}
Future<void> updateMetadata(
String id, {
DroidMetadata? droid,
IOSMetadata? ios,
}) async {
if (droid != null) {
await _sqliteDB.execute(
'UPDATE assets SET size = ?, hash = ?, latitude = ?, longitude = ?, created_at = ?, modified_at = ?, scan_state = 1 WHERE id = ?',
[
droid.size,
droid.hash,
droid.location?.latitude,
droid.location?.longitude,
droid.creationTime,
droid.modificationTime,
id,
],
);
} else if (ios != null) {
// await _sqliteDB.execute(
// 'UPDATE assets SET size = ?, hash = ?, latitude = ?, longitude = ?, created_at = ?, modified_at = ? WHERE id = ?',
// [
// ios.size,
// ios.hash,
// ios.location.latitude,
// ios.location.longitude,
// ios.creationTime.millisecondsSinceEpoch,
// ios.modificationTime.millisecondsSinceEpoch,
// ios.id,
// ],
// );
}
}
Future<Map<String, LocalAssetInfo>> getLocalAssetsInfo(
List<String> ids,
) async {
if (ids.isEmpty) return {};
final stopwatch = Stopwatch()..start();
final result = await _sqliteDB.getAll(
'SELECT id, hash, title, relative_path, scan_state FROM assets WHERE id IN (${List.filled(ids.length, "?").join(",")})',
ids,
);
debugPrint(
"getLocalAssetsInfo complete in ${stopwatch.elapsed.inMilliseconds}ms for ${ids.length} ids",
);
return Map.fromEntries(
result.map(
(row) => MapEntry(row['id'] as String, LocalAssetInfo.fromRow(row)),
),
);
}
Future<List<EnteFile>> getAssets({LocalAssertsParam? params}) async {
final stopwatch = Stopwatch()..start();
final result = await _sqliteDB.getAll(
"SELECT * FROM assets ${params != null ? params.whereClause(addWhere: true) : ""}",
);
debugPrint(
"getAssets complete in ${stopwatch.elapsed.inMilliseconds}ms, params: ${params?.whereClause()}",
);
// if time is greater than 1000ms, print explain analyze out
if (kDebugMode && stopwatch.elapsed.inMilliseconds > 1000) {
final explain = await _sqliteDB.execute(
"EXPLAIN QUERY PLAN SELECT * FROM assets ${params != null ? params.whereClause(addWhere: true) : ""}",
);
debugPrint("getAssets: Explain Query Plan: $explain");
}
stopwatch.reset();
stopwatch.start();
final r =
result.map((row) => LocalDBMappers.assetRowToEnteFile(row)).toList();
debugPrint(
"getAssets mapping completed in ${stopwatch.elapsed.inMilliseconds}ms",
);
return r;
}
Future<List<EnteFile>> getPathAssets(
String pathID, {
LocalAssertsParam? params,
}) async {
final String query =
"SELECT * FROM assets WHERE id IN (SELECT asset_id FROM device_path_assets WHERE path_id = ?) ${params != null ? 'AND ${params.whereClause()}' : "order by created_at desc"}";
debugPrint(query);
final result = await _sqliteDB.getAll(
query,
[pathID],
);
return result.map((row) => LocalDBMappers.assetRowToEnteFile(row)).toList();
}
Future<void> insertDBPaths(List<AssetPathEntity> entries) async {
if (entries.isEmpty) return;
final stopwatch = Stopwatch()..start();
await Future.forEach(entries.slices(_smallTableBatchInsertMaxCount),
(slice) async {
final List<List<Object?>> values =
slice.map((e) => LocalDBMappers.devicePathRow(e)).toList();
await _sqliteDB.executeBatch(
'INSERT INTO device_path ($devicePathColumns) values(${getParams(5)}) ON CONFLICT(path_id) DO UPDATE SET $updateDevicePathColumns',
values,
);
});
debugPrint(
'$runtimeType insertDBPaths complete in ${stopwatch.elapsed.inMilliseconds}ms for ${entries.length} paths',
);
}
Future<List<AssetPathEntity>> getAssetPaths() async {
final result = await _sqliteDB.getAll(
"SELECT * FROM device_path",
);
return result.map((row) => LocalDBMappers.assetPath(row)).toList();
}
Future<void> insertPathToAssetIDs(
Map<String, Set<String>> pathToAssetIDs, {
bool clearOldMappingsIdsInInput = false,
}) async {
if (pathToAssetIDs.isEmpty) return;
final List<List<String>> allValues = [];
pathToAssetIDs.forEach((pathID, assetIDs) {
allValues.addAll(assetIDs.map((assetID) => [pathID, assetID]));
});
if (allValues.isEmpty && !clearOldMappingsIdsInInput) {
return;
}
final stopwatch = Stopwatch()..start();
await _sqliteDB.writeTransaction((tx) async {
if (clearOldMappingsIdsInInput) {
await tx.execute(
"DELETE FROM device_path_assets WHERE path_id IN (${List.generate(pathToAssetIDs.keys.length, (index) => '?').join(',')})",
pathToAssetIDs.keys.toList(),
);
}
const int batchSize = 15000;
for (int i = 0; i < allValues.length; i += batchSize) {
await tx.executeBatch(
'INSERT OR REPLACE INTO device_path_assets (path_id, asset_id) VALUES (?, ?)',
allValues.sublist(
i,
i + batchSize > allValues.length ? allValues.length : i + batchSize,
),
);
}
});
debugPrint(
'$runtimeType insertPathToAssetIDs ${allValues.length} complete in '
'${stopwatch.elapsed.inMilliseconds}ms for '
'${pathToAssetIDs.length} paths (replaced $clearOldMappingsIdsInInput}',
);
}
Future<Set<String>> getAssetsIDs({bool pendingScan = false}) async {
final result = await _sqliteDB.getAll(
"SELECT id FROM assets ${pendingScan ? 'WHERE scan_state != $finalState ORDER BY created_at DESC' : ''}",
);
final ids = <String>{};
for (var row in result) {
ids.add(row["id"] as String);
}
return ids;
}
Future<Set<String>> getAssetsIDsForPath(
String pathID,
) async {
final result = await _sqliteDB.getAll(
"SELECT asset_id FROM device_path_assets WHERE path_id = ? ",
[pathID],
);
final ids = <String>{};
for (var row in result) {
ids.add(row["asset_id"] as String);
}
return ids;
}
Future<Map<String, int>> getIDToCreationTime() async {
final result = await _sqliteDB.getAll(
"SELECT id, created_at FROM assets",
);
final idToCreationTime = <String, int>{};
for (var row in result) {
idToCreationTime[row["id"] as String] = row["created_at"] as int;
}
return idToCreationTime;
}
Future<Map<String, Set<String>>> pathToAssetIDs() async {
final result = await _sqliteDB
.getAll("SELECT path_id, asset_id FROM device_path_assets");
final pathToAssetIDs = <String, Set<String>>{};
for (var row in result) {
final pathID = row["path_id"] as String;
final assetID = row["asset_id"] as String;
if (pathToAssetIDs.containsKey(pathID)) {
pathToAssetIDs[pathID]!.add(assetID);
} else {
pathToAssetIDs[pathID] = {assetID};
}
}
return pathToAssetIDs;
}
Future<void> deleteAssets(Set<String> ids) async {
if (ids.isEmpty) return;
final stopwatch = Stopwatch()..start();
await _sqliteDB.execute(
'DELETE FROM assets WHERE id IN (${List.filled(ids.length, "?").join(",")})',
ids.toList(),
);
debugPrint(
'$runtimeType deleteEntries complete in ${stopwatch.elapsed.inMilliseconds}ms for ${ids.length} assets entries',
);
}
Future<void> deletePaths(Set<String> pathIds) async {
if (pathIds.isEmpty) return;
final stopwatch = Stopwatch()..start();
await _sqliteDB.execute(
'DELETE FROM device_path WHERE path_id IN (${List.filled(pathIds.length, "?").join(",")})',
pathIds.toList(),
);
debugPrint(
'$runtimeType deleteEntries complete in ${stopwatch.elapsed.inMilliseconds}ms for ${pathIds.length} path entries',
);
}
// returns true if either asset queue or shared_assets has any entry for given ownerID
Future<bool> hasAssetQueueOrSharedAsset(int ownerID) async {
final result = await _sqliteDB.getAll(
'''
SELECT 1 FROM asset_upload_queue WHERE owner_id = ?
UNION ALL
SELECT 1 FROM shared_assets WHERE owner_id = ?
LIMIT 1
''',
[ownerID, ownerID],
);
return result.isNotEmpty;
}
Future<(int, int)> getUniqueQueueAndSharedAssetsCount(
int ownerID,
) async {
final queuedAssets = await _sqliteDB.getAll(
'SELECT COUNT(distinct asset_id) as count FROM asset_upload_queue WHERE owner_id = ?',
[ownerID],
);
final sharedAssets = await _sqliteDB.getAll(
'SELECT COUNT(*) as count FROM shared_assets WHERE owner_id = ?',
[ownerID],
);
final queuedCount =
queuedAssets.isNotEmpty ? (queuedAssets.first['count'] as int) : 0;
final sharedCount =
sharedAssets.isNotEmpty ? (sharedAssets.first['count'] as int) : 0;
return (queuedCount, sharedCount);
}
}

View File

@@ -0,0 +1,100 @@
import "dart:io";
import "package:photo_manager/photo_manager.dart";
import "package:photos/models/file/file.dart";
class LocalDBMappers {
const LocalDBMappers._();
static List<Object?> assetsRow(AssetEntity entity) {
return [
entity.id,
entity.type.index,
entity.subtype,
entity.width,
entity.height,
entity.duration,
entity.orientation,
entity.isFavorite ? 1 : 0,
entity.title,
entity.relativePath,
entity.createDateTime.microsecondsSinceEpoch,
entity.modifiedDateTime.microsecondsSinceEpoch,
entity.mimeType,
entity.latitude,
entity.longitude,
0, // scan_state
];
}
static AssetEntity asset(Map<String, dynamic> row) {
return AssetEntity(
id: row['id'] as String,
typeInt: row['type'] as int,
subtype: row['sub_type'] as int,
width: row['width'] as int,
height: row['height'] as int,
duration: row['duration_in_sec'] as int,
orientation: row['orientation'] as int,
isFavorite: (row['is_fav'] as int) == 1,
title: row['title'] as String?,
relativePath: row['relative_path'] as String?,
createDateSecond: (row['created_at'] as int) ~/ 1000000,
modifiedDateSecond: (row['modified_at'] as int) ~/ 1000000,
mimeType: row['mime_type'] as String?,
latitude: row['latitude'] as double?,
longitude: row['longitude'] as double?,
);
}
static EnteFile assetRowToEnteFile(Map<String, dynamic> row) {
final asset = AssetEntity(
id: row['id'] as String,
typeInt: row['type'] as int,
subtype: row['sub_type'] as int,
width: row['width'] as int,
height: row['height'] as int,
duration: row['duration_in_sec'] as int,
orientation: row['orientation'] as int,
isFavorite: (row['is_fav'] as int) == 1,
title: row['title'] as String?,
relativePath: row['relative_path'] as String?,
createDateSecond: (row['created_at'] as int) ~/ 1000000,
modifiedDateSecond: (row['modified_at'] as int) ~/ 1000000,
mimeType: row['mime_type'] as String?,
latitude: row['latitude'] as double?,
longitude: row['longitude'] as double?,
);
return EnteFile.fromAssetSync(asset);
}
static List<Object?> devicePathRow(AssetPathEntity entity) {
return [
entity.id,
entity.name,
entity.albumType,
entity.albumTypeEx?.darwin?.type?.index,
entity.albumTypeEx?.darwin?.subtype?.index,
];
}
static AssetPathEntity assetPath(Map<String, dynamic> row) {
return AssetPathEntity(
id: row['path_id'] as String,
name: row['name'] as String,
albumType: row['album_type'] as int,
albumTypeEx: AlbumType(
darwin: !Platform.isAndroid
? DarwinAlbumType(
type: PMDarwinAssetCollectionTypeExt.fromValue(
row['ios_album_type'] as int?,
),
subtype: PMDarwinAssetCollectionSubtypeExt.fromValue(
row['darwin_subtype'] as int?,
),
)
: null,
),
);
}
}

View File

@@ -0,0 +1,253 @@
import "dart:io";
import "package:flutter/foundation.dart";
import "package:sqlite_async/sqlite_async.dart";
const assetColumns =
"id, type, sub_type, width, height, duration_in_sec, orientation, is_fav, title, relative_path, created_at, modified_at, mime_type, latitude, longitude, scan_state";
const assetUploadQueueColumns =
"dest_collection_id, asset_id, path_id, owner_id, manual";
const androidAssetState = 1;
const androidHashState = 1 << 2;
const androidMediaType = 1 << 3;
const iOSAssetState = 1;
const iOSCloudIdState = 1 << 2;
const iOSAssetHashState = 1 << 3;
final finalState = Platform.isAndroid
? (androidAssetState ^ androidHashState ^ androidMediaType)
: (iOSAssetState ^ iOSCloudIdState ^ iOSAssetHashState);
// Generate the update clause dynamically (excludes 'id')
final String updateAssetColumns = assetColumns
.split(', ')
.where((column) => column != 'id') // Exclude primary key from update
.map((column) => '$column = excluded.$column') // Use excluded virtual table
.join(', ');
const devicePathColumns =
"path_id, name, album_type, ios_album_type, ios_album_subtype";
final String updateDevicePathColumns = devicePathColumns
.split(', ')
.where((column) => column != 'path_id') // Exclude primary key from update
.map((column) => '$column = excluded.$column') // Use excluded virtual table
.join(', ');
const String deviceCollectionWithOneAssetQuery = '''
WITH latest_per_path AS (
SELECT
dpa.path_id,
MAX(a.created_at) as max_created,
count(*) as asset_count
FROM
device_path_assets dpa
JOIN
assets a ON dpa.asset_id = a.id
GROUP BY
dpa.path_id
),
ranked_assets AS (
SELECT
dpa.path_id,
a.*,
ROW_NUMBER() OVER (PARTITION BY dpa.path_id ORDER BY a.id) as rn,
lpp.asset_count
FROM
device_path_assets dpa
JOIN
assets a ON dpa.asset_id = a.id
JOIN
latest_per_path lpp ON dpa.path_id = lpp.path_id AND a.created_at = lpp.max_created
)
SELECT
dp.*,
ra.*,
pc.*
FROM
device_path dp
JOIN
ranked_assets ra ON dp.path_id = ra.path_id AND ra.rn = 1
LEFT JOIN path_backup_config pc
on dp.path_id = pc.device_path_id
''';
class LocalAssertsParam {
int? limit;
int? offset;
String? orderByColumn;
bool isAsc;
(int?, int?)? createAtRange;
LocalAssertsParam({
this.limit,
this.offset,
this.orderByColumn = "created_at",
this.isAsc = false,
this.createAtRange,
});
String get orderBy => orderByColumn == null
? ""
: "ORDER BY $orderByColumn ${isAsc ? "ASC" : "DESC"}";
String get limitOffset => (limit != null && offset != null)
? "LIMIT $limit + OFFSET $offset)"
: (limit != null)
? "LIMIT $limit"
: "";
String get createAtRangeStr => (createAtRange == null ||
createAtRange!.$1 == null)
? ""
: "(created_at BETWEEN ${createAtRange!.$1} AND ${createAtRange!.$2})";
String whereClause({bool addWhere = false}) {
final where = <String>[];
if (createAtRangeStr.isNotEmpty) {
where.add(createAtRangeStr);
}
return (where.isEmpty
? ""
: '${addWhere ? "Where" : ""} ${where.join(" AND ")}') +
" " +
orderBy +
" " +
limitOffset;
}
}
class LocalDBMigration {
static const migrationScripts = [
'''
CREATE TABLE assets (
id TEXT PRIMARY KEY,
type INTEGER NOT NULL,
sub_type INTEGER NOT NULL,
width INTEGER NOT NULL,
height INTEGER NOT NULL,
duration_in_sec INTEGER NOT NULL,
orientation INTEGER NOT NULL,
is_fav INTEGER NOT NULL,
title TEXT NOT NULL,
relative_path TEXT,
created_at INTEGER NOT NULL,
modified_at INTEGER NOT NULL,
mime_type TEXT,
latitude REAL,
longitude REAL,
scan_state INTEGER DEFAULT 0,
hash TEXT,
size INTEGER,
os_metadata TEXT DEFAULT '{}'
);
''',
'''
CREATE INDEX IF NOT EXISTS assets_created_at ON assets(created_at);
''',
'''
CREATE TABLE shared_assets (
dest_collection_id INTEGER NOT NULL,
id TEXT NOT NULL,
name TEXT NOT NULL,
type INTEGER NOT NULL,
created_at INTEGER NOT NULL,
duration_in_sec INTEGER DEFAULT 0,
owner_id INTEGER NOT NULL,
latitude REAL,
longitude REAL,
PRIMARY KEY (dest_collection_id, id)
);
''',
'''
CREATE INDEX IF NOT EXISTS sa_collection_owner ON shared_assets(dest_collection_id, owner_id);
''',
'''
CREATE TABLE device_path (
path_id TEXT PRIMARY KEY,
name TEXT NOT NULL,
album_type INTEGER NOT NULL,
ios_album_type INTEGER,
ios_album_subtype INTEGER
);
''',
'''
CREATE TABLE device_path_assets (
path_id TEXT NOT NULL,
asset_id TEXT NOT NULL,
PRIMARY KEY (path_id, asset_id),
FOREIGN KEY (path_id) REFERENCES device_path(path_id) ON DELETE CASCADE,
FOREIGN KEY (asset_id) REFERENCES assets(id) ON DELETE CASCADE
);
''',
'''
CREATE TABLE queue (
id TEXT NOT NULL,
name TEXT NOT NULL,
PRIMARY KEY (id, name)
);
''',
'''
CREATE TABLE path_backup_config(
device_path_id TEXT PRIMARY KEY,
owner_id INTEGER NOT NULL,
collection_id INTEGER,
should_backup INTEGER NOT NULL DEFAULT 0,
upload_strategy INTEGER NOT NULL DEFAULT 0
);
''',
'''
CREATE TABLE asset_upload_queue (
dest_collection_id INTEGER NOT NULL,
asset_id TEXT NOT NULL,
path_id TEXT,
owner_id INTEGER NOT NULL,
manual INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (dest_collection_id, asset_id),
FOREIGN KEY(asset_id) REFERENCES assets(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_asset_upload_queue_owner_id
ON asset_upload_queue(owner_id)
WHERE owner_id IS NOT NULL;
''',
'''
CREATE INDEX IF NOT EXISTS assets_created_at_desc ON assets(created_at DESC);
''',
'''
CREATE TABLE edited_assets (
id String NOT NULL,
created_at INTEGER NOT NULL,
modified_at INTEGER NOT NULL,
latitude REAL,
longitude REAL,
PRIMARY KEY (id)
FOREIGN KEY (id) REFERENCES assets(id) ON DELETE CASCADE
);
''',
];
static Future<void> migrate(
SqliteDatabase database,
) async {
final result = await database.execute('PRAGMA user_version');
await database.execute("PRAGMA foreign_keys = ON");
final currentVersion = result[0]['user_version'] as int;
final toVersion = migrationScripts.length;
if (currentVersion < toVersion) {
debugPrint("Migrating Local DB from $currentVersion to $toVersion");
await database.writeTransaction((tx) async {
for (int i = currentVersion + 1; i <= toVersion; i++) {
await tx.execute(migrationScripts[i - 1]);
}
await tx.execute('PRAGMA user_version = $toVersion');
});
} else if (currentVersion > toVersion) {
throw AssertionError(
"currentVersion($currentVersion) cannot be greater than toVersion($toVersion)",
);
}
}
}

View File

@@ -0,0 +1,33 @@
import "package:photo_manager/photo_manager.dart";
import "package:photos/db/local/db.dart";
import "package:photos/db/local/mappers.dart";
import "package:photos/db/local/schema.dart";
import "package:photos/models/device_collection.dart";
import "package:photos/models/file/file.dart";
import "package:photos/models/upload_strategy.dart";
extension DeviceAlbums on LocalDB {
Future<List<DeviceCollection>> getDeviceCollections() async {
final List<DeviceCollection> collections = [];
final rows = await sqliteDB.getAll(deviceCollectionWithOneAssetQuery);
for (final row in rows) {
final path = LocalDBMappers.assetPath(row);
AssetEntity? asset;
if (row['id'] != null) {
asset = LocalDBMappers.asset(row);
}
collections.add(
DeviceCollection(
path,
count: row['asset_count'] as int,
thumbnail: asset != null ? EnteFile.fromAssetSync(asset) : null,
shouldBackup: (row['should_backup'] ?? 0) as int == 1,
uploadStrategy:
UploadStrategy.values[(row['upload_strategy'] ?? 0) as int],
),
);
}
return collections;
}
}

View File

@@ -0,0 +1,92 @@
import "package:collection/collection.dart";
import "package:flutter/foundation.dart";
import "package:photos/db/local/db.dart";
import "package:photos/log/devlog.dart";
import "package:photos/models/local/path_config.dart";
import "package:photos/models/upload_strategy.dart";
extension PathBackupConfigTable on LocalDB {
Future<void> insertOrUpdatePathConfigs(
Map<String, bool> pathConfigs,
int ownerID,
) async {
if (pathConfigs.isEmpty) return;
final stopwatch = Stopwatch()..start();
await Future.forEach(
pathConfigs.entries.slices(LocalDB.batchInsertMaxCount), (slice) async {
final List<List<Object?>> values =
slice.map((e) => [e.key, e.value ? 1 : 0, ownerID]).toList();
await sqliteDB.executeBatch(
'INSERT INTO path_backup_config (device_path_id, should_backup, owner_id) VALUES (?, ?, ?) ON CONFLICT(device_path_id) DO UPDATE SET should_backup = ?, owner_id = ?',
values.map((e) => [e[0], e[1], e[2], e[1], e[2]]).toList(),
);
});
debugPrint(
'$runtimeType insertOrUpdatePathConfigs complete in ${stopwatch.elapsed.inMilliseconds}ms for ${pathConfigs.length} paths',
);
}
Future<Set<String>> getBackedUpPathIDs(int ownerID) async {
final stopwatch = Stopwatch()..start();
final result = await sqliteDB.getAll(
'SELECT device_path_id FROM path_backup_config WHERE should_backup = 1 AND owner_id = ?',
[ownerID],
);
final paths = result.map((row) => row['device_path_id'] as String).toSet();
devLog(
'$runtimeType getPathsWithBackupEnabled complete in ${stopwatch.elapsed.inMilliseconds}ms',
name: 'getPathsWithBackupEnabled',
);
return paths;
}
// destCollectionWithBackup returns the non-null collection ids
// for given ownerID for paths that have backup enabled.
Future<Set<int>> destCollectionWithBackup(int ownerID) async {
final stopwatch = Stopwatch()..start();
final result = await sqliteDB.getAll(
'SELECT collection_id FROM path_backup_config WHERE should_backup = 1 AND owner_id = ? AND collection_id IS NOT NULL',
[ownerID],
);
final Set<int> collectionIDs =
result.map((row) => row['collection_id'] as int).whereNotNull().toSet();
devLog(
'$runtimeType destCollectionWithBackup complete in ${stopwatch.elapsed.inMilliseconds}ms',
name: 'destCollectionWithBackup',
);
return collectionIDs;
}
Future<void> updateDestConnection(
String pathID,
int destCollection,
int ownerID,
) async {
await sqliteDB.execute(
'UPDATE path_backup_config SET collection_id = ? WHERE device_path_id = ? AND owner_id = ?',
[destCollection, pathID, ownerID],
);
}
Future<List<PathConfig>> getPathConfigs(int ownerID) async {
final stopwatch = Stopwatch()..start();
final result = await sqliteDB.getAll(
'SELECT * FROM path_backup_config WHERE owner_id = ?',
[ownerID],
);
final configs = result.map((row) {
return PathConfig(
row['device_path_id'] as String,
row['owner_id'] as int,
row['collection_id'] as int?,
(row['should_backup'] as int) == 1,
getUploadType(row['upload_strategy'] as int),
);
}).toList();
devLog(
'$runtimeType getPathConfigs complete in ${stopwatch.elapsed.inMilliseconds}ms',
name: 'getPathConfigs',
);
return configs;
}
}

View File

@@ -0,0 +1,69 @@
import "package:collection/collection.dart";
import "package:photos/db/local/db.dart";
import "package:photos/models/local/shared_asset.dart";
extension SharedAssetsTable on LocalDB {
Future<Set<String>> getSharedAssetsID() async {
final result = await sqliteDB.getAll('SELECT id FROM shared_assets');
return Set.unmodifiable(result.map<String>((row) => row['id'] as String));
}
Future<void> insertSharedAssets(List<SharedAsset> assets) async {
if (assets.isEmpty) return;
await Future.forEach(
assets.slices(LocalDB.batchInsertMaxCount),
(slice) async {
final List<List<Object?>> values =
slice.map((e) => e.rowProps).toList();
await sqliteDB.executeBatch(
'INSERT INTO shared_assets (id, name, type, creation_time, duration_in_seconds, dest_collection_id, owner_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
values,
);
},
);
}
Future<List<SharedAsset>> getSharedAssets() async {
final result = await sqliteDB.getAll(
'SELECT * FROM shared_assets ORDER BY creation_time DESC',
);
return result.map((row) => SharedAsset.fromRow(row)).toList();
}
Future<List<SharedAsset>> getSharedAssetsByCollection(
int collectionID,
) async {
final result = await sqliteDB.getAll(
'SELECT * FROM shared_assets WHERE dest_collection_id = ? ORDER BY creation_time DESC',
[collectionID],
);
return result.map((row) => SharedAsset.fromRow(row)).toList();
}
Future<void> deleteSharedAssetsByCollection(int collectionID) async {
await sqliteDB.execute(
'DELETE FROM shared_assets WHERE dest_collection_id = ?',
[collectionID],
);
}
Future<void> deleteSharedAsset(String assetID) async {
await sqliteDB.execute(
'DELETE FROM shared_assets WHERE id = ?',
[assetID],
);
}
Future<void> deleteSharedAssets(Set<String> assetIDs) async {
if (assetIDs.isEmpty) return;
await Future.forEach(
assetIDs.slices(LocalDB.batchInsertMaxCount),
(slice) async {
await sqliteDB.executeBatch(
'DELETE FROM shared_assets WHERE id = ?',
slice.map((id) => [id]).toList(),
);
},
);
}
}

View File

@@ -0,0 +1,148 @@
import "package:collection/collection.dart";
import "package:flutter/foundation.dart";
import "package:photos/db/local/db.dart";
import "package:photos/db/local/mappers.dart";
import "package:photos/db/local/schema.dart";
import "package:photos/models/file/file.dart";
import "package:photos/models/local/asset_upload_queue.dart";
extension UploadQueueTable on LocalDB {
Future<Set<String>> getQueueAssetIDs(int ownerID) async {
final stopwatch = Stopwatch()..start();
final result = await sqliteDB.getAll(
'SELECT asset_id FROM asset_upload_queue WHERE owner_id = ?',
[ownerID],
);
final assetIDs = result.map((row) => row['asset_id'] as String).toSet();
debugPrint(
'$runtimeType getQueueAssetIDs complete in ${stopwatch.elapsed.inMilliseconds}ms',
);
return assetIDs;
}
Future<void> clearMappingsWithDiffPath(
int ownerID,
Set<String> pathIDs,
) async {
if (pathIDs.isEmpty) {
// delete all mapping with path ids
await sqliteDB.execute(
'DELETE FROM asset_upload_queue WHERE owner_id = ? AND path_id IS NOT NULL',
[ownerID],
);
} else {
// delete mappings where path_id is not null and not in pathIDs
final stopwatch = Stopwatch()..start();
await sqliteDB.execute(
'DELETE FROM asset_upload_queue WHERE owner_id = ? AND path_id IS NOT NULL AND path_id NOT IN (${pathIDs.map((_) => '?').join(',')})',
[ownerID, ...pathIDs],
);
debugPrint(
'$runtimeType clearMappingsWithDiffPath complete in ${stopwatch.elapsed.inMilliseconds}ms for ${pathIDs.length} paths',
);
}
}
Future<bool> existsQueueEntry(AssetUploadQueue entry) async {
final result = await sqliteDB.getAll(
'SELECT 1 FROM asset_upload_queue WHERE asset_id = ? AND owner_id = ? AND dest_collection_id = ?',
[entry.id, entry.ownerId, entry.destCollectionId],
);
return result.isNotEmpty;
}
Future<int> delete(AssetUploadQueue entry) async {
final stopwatch = Stopwatch()..start();
final result = await sqliteDB.execute(
'DELETE FROM asset_upload_queue WHERE asset_id = ? AND owner_id = ? and dest_collection_id = ?',
[entry.id, entry.ownerId, entry.destCollectionId],
);
debugPrint(
'$runtimeType delete complete in ${stopwatch.elapsed.inMilliseconds}ms for entry: $entry',
);
return result.isNotEmpty ? result[0]['changes'] as int : 0;
}
Future<List<(AssetUploadQueue, EnteFile)>> getQueueEntriesWithFiles(
int ownerID, {
int? destCollection,
}) async {
final stopwatch = Stopwatch()..start();
final result = await sqliteDB.getAll(
'SELECT asset_upload_queue.*, assets.* FROM asset_upload_queue JOIN assets ON assets.id = asset_upload_queue.asset_id WHERE owner_id = ? ${destCollection != null ? 'AND dest_collection_id = ?' : ''} ORDER BY created_at DESC',
destCollection != null ? [ownerID, destCollection] : [ownerID],
);
final entries = result
.map(
(row) => (
AssetUploadQueue(
id: row['asset_id'] as String,
pathId: row['path_id'] as String?,
destCollectionId: row['dest_collection_id'] as int,
ownerId: row['owner_id'] as int,
manual: (row['manual'] as int) == 1,
),
EnteFile.fromAssetSync(LocalDBMappers.asset(row)),
),
)
.toList();
debugPrint(
'$runtimeType getQueueEntriesWithFiles complete in ${stopwatch.elapsed.inMilliseconds}ms for ${entries.length} entries',
);
return entries;
}
Future<List<AssetUploadQueue>> getQueueEntries(
int ownerID, {
int? destCollection,
}) async {
final stopwatch = Stopwatch()..start();
final result = await sqliteDB.getAll(
'SELECT asset_upload_queue.*, assets.* FROM asset_upload_queue JOIN assets ON assets.id = asset_upload_queue.asset_id WHERE owner_id = ? ${destCollection != null ? 'AND dest_collection_id = ?' : ''} ORDER BY created_at DESC',
destCollection != null ? [ownerID, destCollection] : [ownerID],
);
final entries = result
.map(
(row) => AssetUploadQueue(
id: row['asset_id'] as String,
pathId: row['path_id'] as String?,
destCollectionId: row['dest_collection_id'] as int,
ownerId: row['owner_id'] as int,
manual: (row['manual'] as int) == 1,
),
)
.toList();
debugPrint(
'$runtimeType getQueueEntries complete in ${stopwatch.elapsed.inMilliseconds}ms for ${entries.length} entries',
);
return entries;
}
Future<void> insertOrUpdateQueue(
Set<String> assetIDs,
int destCollection,
int ownerID, {
String? path,
bool manual = false,
}) async {
if (assetIDs.isEmpty) return;
final stopwatch = Stopwatch()..start();
await Future.forEach(
assetIDs.slices(LocalDB.batchInsertMaxCount),
(slice) async {
final List<List<Object?>> values = slice
.map((e) => [destCollection, e, path, ownerID, manual])
.toList();
await sqliteDB.executeBatch(
'INSERT INTO asset_upload_queue ($assetUploadQueueColumns) VALUES(?,?,?,?,?) ON CONFLICT DO UPDATE SET manual = ?, path_id = ?',
values
.map((e) => [e[0], e[1], e[2], e[3], e[4], manual, path])
.toList(),
);
},
);
debugPrint(
'$runtimeType insertOrUpdateQueue complete in ${stopwatch.elapsed.inMilliseconds}ms for ${assetIDs.length} items',
);
}
}

View File

@@ -9,7 +9,7 @@ import "package:photos/services/machine_learning/face_ml/face_clustering/face_db
abstract class IMLDataDB<T> {
Future<void> bulkInsertFaces(List<Face> faces);
Future<void> updateFaceIdToClusterId(Map<String, String> faceIDToClusterID);
Future<Map<int, int>> faceIndexedFileIds({int minimumMlVersion});
Future<Map<T, int>> faceIndexedFileIds({int minimumMlVersion});
Future<int> getFaceIndexedFileCount({int minimumMlVersion});
Future<Map<String, int>> clusterIdToFaceCount();
Future<Set<String>> getPersonIgnoredClusters(String personID);
@@ -52,7 +52,7 @@ abstract class IMLDataDB<T> {
Future<void> forceUpdateClusterIds(Map<String, String> faceIDToClusterID);
Future<void> removeFaceIdToClusterId(Map<String, String> faceIDToClusterID);
Future<void> removePerson(String personID);
Future<List<FaceDbInfoForClustering>> getFaceInfoForClustering({
Future<List<FaceDbInfoForClustering<T>>> getFaceInfoForClustering({
int maxFaces,
int offset,
int batchSize,
@@ -112,9 +112,9 @@ abstract class IMLDataDB<T> {
});
Future<List<EmbeddingVector>> getAllClipVectors();
Future<Map<int, int>> clipIndexedFileWithVersion();
Future<Map<T, int>> clipIndexedFileWithVersion();
Future<int> getClipIndexedFileCount({int minimumMlVersion});
Future<void> putClip(List<ClipEmbedding> embeddings);
Future<void> putClip<T>(List<ClipEmbedding<T>> embeddings);
Future<void> deleteClipEmbeddings(List<T> fileIDs);
Future<void> deleteClipIndexes();
}

View File

@@ -39,26 +39,10 @@ class ClipVectorDB {
final documentsDirectory = await getApplicationDocumentsDirectory();
final String dbPath = join(documentsDirectory.path, _databaseName);
_logger.info("Opening vectorDB access: DB path " + dbPath);
late VectorDb vectorDB;
try {
vectorDB = VectorDb(
filePath: dbPath,
dimensions: _embeddingDimension,
);
} catch (e, s) {
_logger.severe("Could not open VectorDB at path $dbPath", e, s);
_logger.severe("Deleting the index file and trying again");
await deleteIndexFile();
try {
vectorDB = VectorDb(
filePath: dbPath,
dimensions: _embeddingDimension,
);
} catch (e, s) {
_logger.severe("Still can't open VectorDB at path $dbPath", e, s);
rethrow;
}
}
final vectorDB = VectorDb(
filePath: dbPath,
dimensions: _embeddingDimension,
);
final stats = await getIndexStats(vectorDB);
_logger.info("VectorDB connection opened with stats: ${stats.toString()}");
@@ -88,25 +72,26 @@ class ClipVectorDB {
_migrationDone = true;
}
Future<void> insertEmbedding({
required int fileID,
Future<void> insertEmbedding<T>({
required T fileID,
required List<double> embedding,
}) async {
final db = await _vectorDB;
try {
await db.addVector(key: BigInt.from(fileID), vector: embedding);
final id = fileID as int;
await db.addVector(key: BigInt.from(id), vector: embedding);
} catch (e, s) {
_logger.severe("Error inserting embedding", e, s);
rethrow;
}
}
Future<void> bulkInsertEmbeddings({
required List<int> fileIDs,
Future<void> bulkInsertEmbeddings<T>({
required List<T> fileIDs,
required List<Float32List> embeddings,
}) async {
final db = await _vectorDB;
final bigKeys = Uint64List.fromList(fileIDs);
final bigKeys = Uint64List.fromList(fileIDs.map((e) => e as int).toList());
try {
await db.bulkAddVectors(keys: bigKeys, vectors: embeddings);
} catch (e, s) {

View File

@@ -53,16 +53,15 @@ class MLDataDB with SqlDbBase implements IMLDataDB<int> {
static final MLDataDB instance = MLDataDB._privateConstructor();
static final _migrationScripts = [
createFacesTable,
getCreateFacesTable(false),
createFaceClustersTable,
createClusterPersonTable,
createClusterSummaryTable,
createNotPersonFeedbackTable,
fcClusterIDIndex,
createClipEmbeddingsTable,
getCreateClipEmbeddingsTable(false),
createFileDataTable,
createFaceCacheTable,
createTextEmbeddingsCacheTable,
];
// only have a single app-wide reference to the database
@@ -81,10 +80,10 @@ class MLDataDB with SqlDbBase implements IMLDataDB<int> {
final asyncDBConnection =
SqliteDatabase(path: databaseDirectory, maxReaders: 2);
final stopwatch = Stopwatch()..start();
_logger.info("MLDataDB: Starting migration");
_logger.info("$runtimeType: Starting migration");
await migrate(asyncDBConnection, _migrationScripts);
_logger.info(
"MLDataDB Migration took ${stopwatch.elapsedMilliseconds} ms",
"$runtimeType Migration took ${stopwatch.elapsedMilliseconds} ms",
);
stopwatch.stop();
@@ -361,10 +360,10 @@ class MLDataDB with SqlDbBase implements IMLDataDB<int> {
(element) => (element[fileIDColumn] as int) == avatarFileId,
);
if (row != null) {
return mapRowToFace(row);
return mapRowToFace<int>(row);
}
}
return mapRowToFace(faceMaps.first);
return mapRowToFace<int>(faceMaps.first);
}
}
if (clusterID != null) {
@@ -412,7 +411,7 @@ class MLDataDB with SqlDbBase implements IMLDataDB<int> {
if (maps.isEmpty) {
return null;
}
return maps.map((e) => mapRowToFace(e)).toList();
return maps.map((e) => mapRowToFace<int>(e)).toList();
}
@override
@@ -429,7 +428,7 @@ class MLDataDB with SqlDbBase implements IMLDataDB<int> {
}
final result = <int, List<FaceWithoutEmbedding>>{};
for (final map in maps) {
final face = mapRowToFaceWithoutEmbedding(map);
final face = mapRowToFaceWithoutEmbedding<int>(map);
final fileID = map[fileIDColumn] as int;
result.putIfAbsent(fileID, () => <FaceWithoutEmbedding>[]).add(face);
}
@@ -727,7 +726,7 @@ class MLDataDB with SqlDbBase implements IMLDataDB<int> {
}
@override
Future<List<FaceDbInfoForClustering>> getFaceInfoForClustering({
Future<List<FaceDbInfoForClustering<int>>> getFaceInfoForClustering({
int maxFaces = 20000,
int offset = 0,
int batchSize = 10000,
@@ -739,7 +738,8 @@ class MLDataDB with SqlDbBase implements IMLDataDB<int> {
);
final db = await instance.asyncDB;
final List<FaceDbInfoForClustering> result = <FaceDbInfoForClustering>[];
final List<FaceDbInfoForClustering<int>> result =
<FaceDbInfoForClustering<int>>[];
while (true) {
// Query a batch of rows
final List<Map<String, dynamic>> maps = await db.getAll(
@@ -759,7 +759,7 @@ class MLDataDB with SqlDbBase implements IMLDataDB<int> {
final faceIdToClusterId = await getFaceIdsToClusterIds(faceIds);
for (final map in maps) {
final faceID = map[faceIDColumn] as String;
final faceInfo = FaceDbInfoForClustering(
final faceInfo = FaceDbInfoForClustering<int>(
faceID: faceID,
clusterId: faceIdToClusterId[faceID],
embeddingBytes: map[embeddingColumn] as Uint8List,
@@ -1136,7 +1136,7 @@ class MLDataDB with SqlDbBase implements IMLDataDB<int> {
final db = await instance.asyncDB;
if (faces) {
await db.execute(deleteFacesTable);
await db.execute(createFacesTable);
await db.execute(getCreateFacesTable(false));
await db.execute(deleteFaceClustersTable);
await db.execute(createFaceClustersTable);
await db.execute(fcClusterIDIndex);
@@ -1336,8 +1336,8 @@ class MLDataDB with SqlDbBase implements IMLDataDB<int> {
"Got ${fileIDs.length} valid embeddings, $weirdCount weird embeddings",
);
await ClipVectorDB.instance
.bulkInsertEmbeddings(fileIDs: fileIDs, embeddings: embeddings);
await ClipVectorDB.instance.bulkInsertEmbeddings<int>(
fileIDs: fileIDs, embeddings: embeddings);
_logger.info("Inserted ${fileIDs.length} embeddings to ClipVectorDB");
processedCount += fileIDs.length;
offset += batchSize;
@@ -1397,7 +1397,7 @@ class MLDataDB with SqlDbBase implements IMLDataDB<int> {
}
@override
Future<void> putClip(List<ClipEmbedding> embeddings) async {
Future<void> putClip<int>(List<ClipEmbedding<int>> embeddings) async {
if (embeddings.isEmpty) return;
final db = await instance.asyncDB;
if (embeddings.length == 1) {
@@ -1407,8 +1407,9 @@ class MLDataDB with SqlDbBase implements IMLDataDB<int> {
);
if (flagService.enableVectorDb &&
await ClipVectorDB.instance.checkIfMigrationDone()) {
final e = embeddings.first.fileID;
await ClipVectorDB.instance.insertEmbedding(
fileID: embeddings.first.fileID,
fileID: e,
embedding: embeddings.first.embedding,
);
}
@@ -1430,56 +1431,6 @@ class MLDataDB with SqlDbBase implements IMLDataDB<int> {
Bus.instance.fire(EmbeddingUpdatedEvent());
}
/// WARNING: don't confuse this with [putClip]. If you're not sure, use [putClip]
Future<void> putRepeatedTextEmbeddingCache(
String query,
List<double> embedding,
) async {
final db = await asyncDB;
await db.execute(
'INSERT OR REPLACE INTO $textEmbeddingsCacheTable '
'($textQueryColumn, $embeddingColumn, $mlVersionColumn, $createdAtColumn) '
'VALUES (?, ?, ?, ?)',
[
query,
Float32List.fromList(embedding).buffer.asUint8List(),
clipMlVersion,
DateTime.now().millisecondsSinceEpoch,
],
);
}
/// WARNING: don't confuse this with [getAllClipVectors]. If you're not sure, use [getAllClipVectors]
Future<List<double>?> getRepeatedTextEmbeddingCache(String query) async {
final db = await asyncDB;
final results = await db.getAll(
'SELECT $embeddingColumn, $mlVersionColumn, $createdAtColumn '
'FROM $textEmbeddingsCacheTable '
'WHERE $textQueryColumn = ?',
[query],
);
if (results.isEmpty) return null;
final threeMonthsAgo =
DateTime.now().millisecondsSinceEpoch - (90 * 24 * 60 * 60 * 1000);
// Find first valid entry
for (final result in results) {
if (result[mlVersionColumn] == clipMlVersion &&
result[createdAtColumn] as int > threeMonthsAgo) {
return Float32List.view((result[embeddingColumn] as Uint8List).buffer);
}
}
// No valid entry found, clean up
await db.execute(
'DELETE FROM $textEmbeddingsCacheTable WHERE $textQueryColumn = ?',
[query],
);
return null;
}
@override
Future<void> deleteClipEmbeddings(List<int> fileIDs) async {
final db = await instance.asyncDB;

View File

@@ -7,7 +7,7 @@ import "package:photos/models/ml/face/face.dart";
import "package:photos/models/ml/face/face_with_embedding.dart";
import "package:photos/models/ml/ml_versions.dart";
Map<String, dynamic> mapRemoteToFaceDB(Face face) {
Map<String, dynamic> mapRemoteToFaceDB<T>(Face<T> face) {
return {
faceIDColumn: face.faceID,
fileIDColumn: face.fileID,
@@ -24,10 +24,10 @@ Map<String, dynamic> mapRemoteToFaceDB(Face face) {
};
}
Face mapRowToFace(Map<String, dynamic> row) {
Face mapRowToFace<T>(Map<String, dynamic> row) {
return Face(
row[faceIDColumn] as String,
row[fileIDColumn] as int,
row[fileIDColumn] as T,
EVector.fromBuffer(row[embeddingColumn] as List<int>).values,
row[faceScore] as double,
Detection.fromJson(json.decode(row[faceDetectionColumn] as String)),
@@ -39,10 +39,12 @@ Face mapRowToFace(Map<String, dynamic> row) {
);
}
FaceWithoutEmbedding mapRowToFaceWithoutEmbedding(Map<String, dynamic> row) {
return FaceWithoutEmbedding(
FaceWithoutEmbedding<T> mapRowToFaceWithoutEmbedding<T>(
Map<String, dynamic> row,
) {
return FaceWithoutEmbedding<T>(
row[faceIDColumn] as String,
row[fileIDColumn] as int,
row[fileIDColumn] as T,
row[faceScore] as double,
Detection.fromJson(json.decode(row[faceDetectionColumn] as String)),
row[faceBlur] as double,

File diff suppressed because it is too large Load Diff

View File

@@ -16,11 +16,10 @@ const mlVersionColumn = 'ml_version';
const personIdColumn = 'person_id';
const clusterIDColumn = 'cluster_id';
const personOrClusterIdColumn = 'person_or_cluster_id';
const textQueryColumn = 'text_query';
const createdAtColumn = 'created_at';
const createFacesTable = '''CREATE TABLE IF NOT EXISTS $facesTable (
$fileIDColumn INTEGER NOT NULL,
String getCreateFacesTable(bool isOfflineDB) {
return '''CREATE TABLE IF NOT EXISTS $facesTable (
$fileIDColumn ${isOfflineDB ? 'TEXT' : 'INTEGER'} NOT NULL,
$faceIDColumn TEXT NOT NULL UNIQUE,
$faceDetectionColumn TEXT NOT NULL,
$embeddingColumn BLOB NOT NULL,
@@ -33,6 +32,7 @@ const createFacesTable = '''CREATE TABLE IF NOT EXISTS $facesTable (
PRIMARY KEY($fileIDColumn, $faceIDColumn)
);
''';
}
const deleteFacesTable = 'DELETE FROM $facesTable';
// End of Faces Table Fields & Schema Queries
@@ -100,18 +100,20 @@ const deleteNotPersonFeedbackTable = 'DELETE FROM $notPersonFeedback';
// ## CLIP EMBEDDINGS TABLE
const clipTable = 'clip';
const createClipEmbeddingsTable = '''
CREATE TABLE IF NOT EXISTS $clipTable (
$fileIDColumn INTEGER NOT NULL,
String getCreateClipEmbeddingsTable(bool isOfflineDB) {
return '''CREATE TABLE IF NOT EXISTS $clipTable (
$fileIDColumn ${isOfflineDB ? 'TEXT' : 'INTEGER'} NOT NULL,
$embeddingColumn BLOB NOT NULL,
$mlVersionColumn INTEGER NOT NULL,
PRIMARY KEY ($fileIDColumn)
PRIMARY KEY($fileIDColumn)
);
''';
''';
}
const deleteClipEmbeddingsTable = 'DELETE FROM $clipTable';
const fileDataTable = 'filedata';
const createFileDataTable = '''
CREATE TABLE IF NOT EXISTS $fileDataTable (
$fileIDColumn INTEGER NOT NULL,
@@ -139,18 +141,3 @@ CREATE TABLE IF NOT EXISTS $faceCacheTable (
''';
const deleteFaceCacheTable = 'DELETE FROM $faceCacheTable';
// ## TEXT EMBEDDINGS CACHE TABLE
const textEmbeddingsCacheTable = 'text_embeddings_cache';
const createTextEmbeddingsCacheTable = '''
CREATE TABLE IF NOT EXISTS $textEmbeddingsCacheTable (
$textQueryColumn TEXT NOT NULL,
$embeddingColumn BLOB NOT NULL,
$mlVersionColumn INTEGER NOT NULL,
$createdAtColumn INTEGER NOT NULL,
PRIMARY KEY ($textQueryColumn)
);
''';
const deleteTextEmbeddingsCacheTable = 'DELETE FROM $textEmbeddingsCacheTable';

View File

@@ -0,0 +1,193 @@
import "dart:io";
import "package:collection/collection.dart";
import "package:flutter/foundation.dart";
import "package:path/path.dart";
import "package:path_provider/path_provider.dart";
import "package:photos/db/common/base.dart";
import "package:photos/db/remote/mappers.dart";
import "package:photos/db/remote/schema.dart";
import "package:photos/log/devlog.dart";
import "package:photos/models/api/diff/diff.dart";
import "package:photos/models/collection/collection.dart";
import "package:photos/models/file/remote/asset.dart";
import "package:sqlite_async/sqlite_async.dart";
// ignore: constant_identifier_names
enum RemoteTable { collections, collection_files, files, entities, trash }
class RemoteDB with SqlDbBase {
static const _databaseName = "remotex6.db";
static const _batchInsertMaxCount = 1000;
late final SqliteDatabase _sqliteDB;
Future<void> init() async {
devLog("Starting RemoteDB init");
final Directory documentsDirectory =
await getApplicationDocumentsDirectory();
final String path = join(documentsDirectory.path, _databaseName);
final db = SqliteDatabase(path: path);
await migrate(db, RemoteDBMigration.migrationScripts, onForeignKey: true);
_sqliteDB = db;
debugPrint("RemoteDB init complete $path");
}
SqliteDatabase get sqliteDB => _sqliteDB;
Future<List<Collection>> getAllCollections() async {
final result = <Collection>[];
final cursor = await _sqliteDB.getAll("SELECT * FROM collections");
for (final row in cursor) {
result.add(Collection.fromRow(row));
}
return result;
}
Future<void> clearAllTables() async {
final stopwatch = Stopwatch()..start();
await Future.wait([
_sqliteDB.execute('DELETE FROM collections'),
_sqliteDB.execute('DELETE FROM collection_files'),
_sqliteDB.execute('DELETE FROM files'),
_sqliteDB.execute('DELETE FROM files_metadata'),
_sqliteDB.execute('DELETE FROM trash'),
_sqliteDB.execute('DELETE FROM upload_mapping'),
]);
debugPrint(
'$runtimeType clearAllTables complete in ${stopwatch.elapsed.inMilliseconds}ms',
);
}
Future<Map<int, int>> getCollectionIDToUpdationTime() async {
final result = <int, int>{};
final cursor = await _sqliteDB.getAll(
"SELECT id, updation_time FROM collections where is_deleted = 0",
);
for (final row in cursor) {
result[row['id'] as int] = row['updation_time'] as int;
}
return result;
}
Future<List<RemoteAsset>> getRemoteAssets() async {
final result = <RemoteAsset>[];
final cursor = await _sqliteDB.getAll(
"SELECT * FROM files",
);
for (final row in cursor) {
result.add(fromFilesRow(row));
}
return result;
}
Future<void> insertCollections(List<Collection> collections) async {
if (collections.isEmpty) return;
final stopwatch = Stopwatch()..start();
await Future.forEach(collections.slices(_batchInsertMaxCount),
(slice) async {
final List<List<Object?>> values =
slice.map((e) => e.rowValiues()).toList();
await _sqliteDB.executeBatch(
'INSERT INTO collections ($collectionColumns) values($collectionValuePlaceHolder) ON CONFLICT(id) DO UPDATE SET $updateCollectionColumns',
values,
);
});
debugPrint(
'$runtimeType insertCollections complete in ${stopwatch.elapsed.inMilliseconds}ms for ${collections.length} collections',
);
}
Future<List<RemoteAsset>> insertDiffItems(
List<DiffItem> items,
) async {
if (items.isEmpty) return [];
final List<RemoteAsset> assets = [];
final stopwatch = Stopwatch()..start();
await Future.forEach(items.slices(_batchInsertMaxCount), (slice) async {
final List<List<Object?>> collectionFileValues = [];
final List<List<Object?>> fileValues = [];
final List<List<Object?>> fileMetadataValues = [];
for (final item in slice) {
final rAsset = item.fileItem.toRemoteAsset();
collectionFileValues.add(item.collectionFileRowValues());
fileMetadataValues.add(item.fileItem.filesMetadataRowValues());
fileValues.add(remoteAssetToRow(rAsset));
assets.add(rAsset);
}
await Future.wait([
_sqliteDB.executeBatch(
'INSERT INTO collection_files ($collectionFilesColumns) values(?, ?, ?, ?, ?, ?) ON CONFLICT(file_id, collection_id) DO UPDATE SET $collectionFilesUpdateColumns',
collectionFileValues,
),
_sqliteDB.executeBatch(
'INSERT INTO files ($filesColumns) values(${getParams(23)}) ON CONFLICT(id) DO UPDATE SET $filesUpdateColumns',
fileValues,
),
_sqliteDB.executeBatch(
'INSERT INTO files_metadata ($filesMetadataColumns) values(${getParams(5)}) ON CONFLICT(id) DO UPDATE SET $filesMetadataUpdateColumns',
fileMetadataValues,
),
]);
});
debugPrint(
'$runtimeType insertCollectionFilesDiff complete in ${stopwatch.elapsed.inMilliseconds}ms for ${items.length}',
);
return assets;
}
Future<void> deleteFilesDiff(
List<DiffItem> items,
) async {
final int collectionID = items.first.collectionID;
final stopwatch = Stopwatch()..start();
await Future.forEach(items.slices(_batchInsertMaxCount), (slice) async {
await _sqliteDB.execute(
'DELETE FROM collection_files WHERE file_id IN (${slice.map((e) => e.fileID).join(',')}) AND collection_id = $collectionID',
);
});
debugPrint(
'$runtimeType deleteCollectionFilesDiff complete in ${stopwatch.elapsed.inMilliseconds}ms for ${items.length}',
);
}
Future<void> deleteEntries<T>(Set<T> ids, RemoteTable table) async {
if (ids.isEmpty) return;
final stopwatch = Stopwatch()..start();
await _sqliteDB.execute(
'DELETE FROM ${table.name.toLowerCase()} WHERE id IN (${ids.join(',')})',
);
debugPrint(
'$runtimeType deleteEntries complete in ${stopwatch.elapsed.inMilliseconds}ms for ${ids.length} $table entries',
);
}
Future<int> rowCount(
RemoteTable table,
) async {
final row = await _sqliteDB.get(
'SELECT COUNT(*) as count FROM ${table.name}',
);
return row['count'] as int;
}
Future<Set<T>> _getByIds<T>(
Set<int> ids,
String table,
T Function(
Map<String, Object?> row,
) mapRow, {
String columnName = "id",
}) async {
final result = <T>{};
if (ids.isNotEmpty) {
final rows = await _sqliteDB.getAll(
'SELECT * from $table where $columnName IN (${ids.join(',')})',
);
for (final row in rows) {
result.add(mapRow(row));
}
}
return result;
}
}

Some files were not shown because too many files have changed in this diff Show More