Compare commits
401 Commits
translatio
...
remote_db
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38503e8673 | ||
|
|
c5319f2ba8 | ||
|
|
5d851d8f90 | ||
|
|
dd76317bb0 | ||
|
|
5519981f9c | ||
|
|
1c249560c0 | ||
|
|
130cc9137c | ||
|
|
95f2d282b5 | ||
|
|
e6668606db | ||
|
|
316c767ffa | ||
|
|
c08b0f7aa4 | ||
|
|
b5a4bcf98f | ||
|
|
dc06a2e193 | ||
|
|
c29446e00a | ||
|
|
5aa36921d8 | ||
|
|
3b3af65d74 | ||
|
|
9ad88d3908 | ||
|
|
012e9091f0 | ||
|
|
8ff0f237e7 | ||
|
|
c80e4a65b8 | ||
|
|
c317f2494f | ||
|
|
264b0b151a | ||
|
|
e5cb3e7005 | ||
|
|
adc1939638 | ||
|
|
2cc1c36b7b | ||
|
|
b99450615e | ||
|
|
5de1f0c93b | ||
|
|
ee902a5ccb | ||
|
|
9e56afaa73 | ||
|
|
77f8c3f712 | ||
|
|
f7a0a414db | ||
|
|
bd49b8464b | ||
|
|
0cbfa319ba | ||
|
|
95e05f167c | ||
|
|
84ef4c2d0b | ||
|
|
53c14dff01 | ||
|
|
02ef29fd8f | ||
|
|
1848f1a94b | ||
|
|
276c38236f | ||
|
|
5e3e3b4427 | ||
|
|
b8dab3ea1c | ||
|
|
2dc32f5339 | ||
|
|
9fd6bc4974 | ||
|
|
3740e9e29d | ||
|
|
118dde38a2 | ||
|
|
cd15fe86c6 | ||
|
|
693a40cc24 | ||
|
|
2cfa8497da | ||
|
|
3f6b9d7ae6 | ||
|
|
e71b6dbb63 | ||
|
|
44722c40c2 | ||
|
|
bfd13b99d2 | ||
|
|
e82d878fa8 | ||
|
|
0587813c70 | ||
|
|
b7712a7c51 | ||
|
|
213f3cd122 | ||
|
|
d103274da5 | ||
|
|
28b244ebc5 | ||
|
|
7d24dea7bc | ||
|
|
4a358a7793 | ||
|
|
699249cd26 | ||
|
|
7125ac7419 | ||
|
|
de67b6e9fc | ||
|
|
39868de5d9 | ||
|
|
751550d469 | ||
|
|
6f0034dd9d | ||
|
|
7bd6180ebf | ||
|
|
5a3ae5f97c | ||
|
|
e447058573 | ||
|
|
8a7e8b8237 | ||
|
|
ed7b1be591 | ||
|
|
9a5ef8b634 | ||
|
|
c2668387b5 | ||
|
|
d265cf62c7 | ||
|
|
a67c3b0624 | ||
|
|
306c78fbb0 | ||
|
|
e4f9dd6b33 | ||
|
|
5fec59f0fe | ||
|
|
53050ca25e | ||
|
|
926aa42168 | ||
|
|
f4f8141b99 | ||
|
|
3d044dd3d4 | ||
|
|
8699ad2f01 | ||
|
|
01aad531f1 | ||
|
|
6340d9f646 | ||
|
|
0f944b1796 | ||
|
|
1d8533168f | ||
|
|
2fca1ba534 | ||
|
|
2d386c769b | ||
|
|
1b41d81839 | ||
|
|
487156c7df | ||
|
|
35c54111e7 | ||
|
|
d71340fbdd | ||
|
|
f32ea85ee2 | ||
|
|
6397ab888a | ||
|
|
5a73043b63 | ||
|
|
f258c40e98 | ||
|
|
36b6476049 | ||
|
|
c4c5ea150f | ||
|
|
1956b3788b | ||
|
|
72cbddff6d | ||
|
|
17670d5538 | ||
|
|
8cfd80663e | ||
|
|
f285e2d706 | ||
|
|
f60e074dd5 | ||
|
|
140eae6859 | ||
|
|
23ee022472 | ||
|
|
b1cf3f9fb0 | ||
|
|
dbbb80a817 | ||
|
|
91a10634cc | ||
|
|
0a2c230254 | ||
|
|
6745b110df | ||
|
|
7061161181 | ||
|
|
f0026f0a81 | ||
|
|
acec985bcb | ||
|
|
c8103a9e06 | ||
|
|
02f64ad45f | ||
|
|
d0931d1d0e | ||
|
|
5c78de5355 | ||
|
|
1aa9f61419 | ||
|
|
0f2b51d1a5 | ||
|
|
9fef560d15 | ||
|
|
09c7bfd717 | ||
|
|
2ff059a701 | ||
|
|
70b043d34a | ||
|
|
15a00379b5 | ||
|
|
7246ade2ae | ||
|
|
7cad0a83d2 | ||
|
|
e6703aef65 | ||
|
|
662dfad7ca | ||
|
|
466ab30f8b | ||
|
|
12c1845a5f | ||
|
|
2d0202df36 | ||
|
|
2ec4f5a7e5 | ||
|
|
6723bed1b0 | ||
|
|
84e8ce519e | ||
|
|
1b50528181 | ||
|
|
08dff77ad4 | ||
|
|
713abce89a | ||
|
|
da15593a47 | ||
|
|
fc31cc61d1 | ||
|
|
9c6259b713 | ||
|
|
ed603232a5 | ||
|
|
ccd89d3451 | ||
|
|
647b2ef4a7 | ||
|
|
f54c79462e | ||
|
|
eb81d96ddf | ||
|
|
57363a24ef | ||
|
|
9431995e8c | ||
|
|
64b86376f6 | ||
|
|
a825367c49 | ||
|
|
1ffbb27ac5 | ||
|
|
d4add9f7ef | ||
|
|
541494613f | ||
|
|
2b3427e40b | ||
|
|
a57c9e881d | ||
|
|
d15f1e15ce | ||
|
|
0411f8ad40 | ||
|
|
2981816c90 | ||
|
|
a6de98ef68 | ||
|
|
18156ce8bc | ||
|
|
458c1cf86d | ||
|
|
90c0874608 | ||
|
|
928ffba4d7 | ||
|
|
0701212540 | ||
|
|
347bf4d2e0 | ||
|
|
2729edfded | ||
|
|
4e8d2c5cea | ||
|
|
84e9336672 | ||
|
|
cecdea3f93 | ||
|
|
37674deba0 | ||
|
|
733be57df8 | ||
|
|
74df52baf1 | ||
|
|
b817c4475e | ||
|
|
f95dac31d2 | ||
|
|
151289b24a | ||
|
|
5dac9d4dd6 | ||
|
|
43b9dbdc54 | ||
|
|
9d3caaa5d5 | ||
|
|
7eda2ed24e | ||
|
|
30df5271b4 | ||
|
|
4b1f7612a3 | ||
|
|
bf6521e8d5 | ||
|
|
4bac1bcb1d | ||
|
|
b123635584 | ||
|
|
d815143bb4 | ||
|
|
ff6228497f | ||
|
|
7469578e77 | ||
|
|
76afef6149 | ||
|
|
2b3178495a | ||
|
|
d6f3ff8db3 | ||
|
|
7b0ef2b0c0 | ||
|
|
35f95010ea | ||
|
|
233f0ec1e1 | ||
|
|
64820ff5fa | ||
|
|
86ffd4e1e6 | ||
|
|
436a02d352 | ||
|
|
0f3b8bae48 | ||
|
|
0f270a379f | ||
|
|
609f6b8e18 | ||
|
|
7896b397c2 | ||
|
|
097078bd24 | ||
|
|
e43e3c4230 | ||
|
|
439f1ff0fb | ||
|
|
e5d78cfd99 | ||
|
|
bd76e66abf | ||
|
|
9e6d7908a9 | ||
|
|
281735e172 | ||
|
|
bc93aca110 | ||
|
|
df6e409ca1 | ||
|
|
7489821434 | ||
|
|
52e0f04ec2 | ||
|
|
ad88ce632c | ||
|
|
26222ec836 | ||
|
|
beeaee4fd9 | ||
|
|
dc98c7bcf5 | ||
|
|
82497563c2 | ||
|
|
4a9b4520d2 | ||
|
|
756c8e5b7d | ||
|
|
dffb920cab | ||
|
|
fa7ddbba0c | ||
|
|
13a068969c | ||
|
|
717e8c8b7e | ||
|
|
015adb595c | ||
|
|
91635d2e7d | ||
|
|
cd3499a004 | ||
|
|
92a964cda6 | ||
|
|
a9ba615962 | ||
|
|
f2b0c11622 | ||
|
|
83b89b6bbf | ||
|
|
ee0a858302 | ||
|
|
b34c923a66 | ||
|
|
fd927d038b | ||
|
|
64e9902f57 | ||
|
|
c3af79d113 | ||
|
|
d87e679650 | ||
|
|
7e2242dc69 | ||
|
|
9adc207b02 | ||
|
|
36049f6633 | ||
|
|
9341bc95ee | ||
|
|
252dca1a01 | ||
|
|
70501054d2 | ||
|
|
be012e0a28 | ||
|
|
340a0c097f | ||
|
|
7fc8649455 | ||
|
|
c97a313edb | ||
|
|
480fdc84dc | ||
|
|
c92ef45c9a | ||
|
|
ca62012a6f | ||
|
|
151a0d13a4 | ||
|
|
747b1b84c6 | ||
|
|
e060fb9823 | ||
|
|
cd377149bc | ||
|
|
d9e22a489b | ||
|
|
524db74bf5 | ||
|
|
e1222d51a9 | ||
|
|
1c68f0bb60 | ||
|
|
f6419caf5c | ||
|
|
441bcbd187 | ||
|
|
4ad3927348 | ||
|
|
4ae15e5966 | ||
|
|
d67d1d3df8 | ||
|
|
07e1d33ca8 | ||
|
|
50e15fa56c | ||
|
|
3f262c5ba2 | ||
|
|
7f34870e3a | ||
|
|
8ba5013926 | ||
|
|
e9a24efecb | ||
|
|
eaf74e4059 | ||
|
|
e9e1c3ca27 | ||
|
|
eb34533aed | ||
|
|
cd042e741e | ||
|
|
6e944b0b55 | ||
|
|
18b6b499dd | ||
|
|
9205ef8219 | ||
|
|
1ecdbdb88e | ||
|
|
e2bb4d723e | ||
|
|
cfe32c47f0 | ||
|
|
a7f6b6589d | ||
|
|
715e305e09 | ||
|
|
f7330be52c | ||
|
|
4cce54a0c6 | ||
|
|
1850e9a2a6 | ||
|
|
21e2b589cc | ||
|
|
b7f8deb452 | ||
|
|
b6ffb3ca22 | ||
|
|
9351a52800 | ||
|
|
c7510024c0 | ||
|
|
63d3b1c94b | ||
|
|
cec27b40a4 | ||
|
|
2f8d0d1957 | ||
|
|
7f0a36f110 | ||
|
|
355367a601 | ||
|
|
c66da422cd | ||
|
|
6f90fad4a2 | ||
|
|
bcd6f55376 | ||
|
|
b2766a0d4f | ||
|
|
ec1b95b0cd | ||
|
|
4369317a4d | ||
|
|
b18298dc62 | ||
|
|
b3e467a1a4 | ||
|
|
f8cd3d9fb4 | ||
|
|
6d4756ca4b | ||
|
|
676bbb4d88 | ||
|
|
da8edfd34e | ||
|
|
bf453cfaac | ||
|
|
35f41f044e | ||
|
|
acff269695 | ||
|
|
2b4f96dbb7 | ||
|
|
d5796e2abb | ||
|
|
afe9690891 | ||
|
|
fc619bbd03 | ||
|
|
a198331ffd | ||
|
|
c58fe5358d | ||
|
|
defc5164b9 | ||
|
|
97d4fb0693 | ||
|
|
e708564cb9 | ||
|
|
92068e026b | ||
|
|
8a079ab4f4 | ||
|
|
b41c57cb8d | ||
|
|
87167e49fc | ||
|
|
089d1dcd10 | ||
|
|
db02e66124 | ||
|
|
964066bf31 | ||
|
|
643220e595 | ||
|
|
e871498161 | ||
|
|
31a88b74df | ||
|
|
1801258fea | ||
|
|
27acc2125b | ||
|
|
60b7a91756 | ||
|
|
b38a01820d | ||
|
|
347d5a7a72 | ||
|
|
82979ac729 | ||
|
|
5768eeb1fe | ||
|
|
7a9ff9877a | ||
|
|
3565540a61 | ||
|
|
06d78e5d6a | ||
|
|
6c11b76c11 | ||
|
|
749cfde7d8 | ||
|
|
6bcaa8ae26 | ||
|
|
99c6318b0e | ||
|
|
abf789a4aa | ||
|
|
f5d3712cbb | ||
|
|
45dd540abc | ||
|
|
865a736bdd | ||
|
|
75bd1bfef6 | ||
|
|
4cf67fe171 | ||
|
|
65895328dc | ||
|
|
a1c20b9c8a | ||
|
|
de9def5370 | ||
|
|
9cc723a280 | ||
|
|
16beae2a82 | ||
|
|
2e25c38324 | ||
|
|
7e691f84e4 | ||
|
|
d99593fc85 | ||
|
|
d4877ea446 | ||
|
|
5f4c748886 | ||
|
|
eaab58c62a | ||
|
|
c1c020402e | ||
|
|
028f4e61d2 | ||
|
|
abc6f56247 | ||
|
|
822eb59761 | ||
|
|
fade7859ab | ||
|
|
f85047fb28 | ||
|
|
3f81c9beae | ||
|
|
b7fa8d7c89 | ||
|
|
5f75c5fc3f | ||
|
|
98eaee3b9e | ||
|
|
59bd039bed | ||
|
|
3e90126a55 | ||
|
|
dabcc0aeb5 | ||
|
|
c4b99af0e2 | ||
|
|
679c12bb90 | ||
|
|
3ef8ece8c0 | ||
|
|
24f8cf188a | ||
|
|
233838da3e | ||
|
|
06c4866c75 | ||
|
|
fa71acf91a | ||
|
|
81d40826b3 | ||
|
|
2a14b5e5a3 | ||
|
|
63bbca09f3 | ||
|
|
cbff68bc42 | ||
|
|
070ab80be9 | ||
|
|
0efbf407d3 | ||
|
|
ab22b28695 | ||
|
|
d3466d7efe | ||
|
|
5945f2aaad | ||
|
|
4c79b9cb92 | ||
|
|
4676c363d2 | ||
|
|
f75807d8f0 | ||
|
|
578541308a | ||
|
|
30cded4d3d | ||
|
|
be2aee6baa | ||
|
|
fae23df6eb | ||
|
|
2a1f2aded1 | ||
|
|
6e85d24286 | ||
|
|
0bcc676e44 | ||
|
|
b5a9bab5c6 | ||
|
|
5849d14cd9 | ||
|
|
77cde87927 | ||
|
|
670d6e8470 |
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -382,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,
|
||||
|
||||
@@ -54,5 +54,4 @@
|
||||
<data android:mimeType="text/plain"/>
|
||||
</intent>
|
||||
</queries>
|
||||
<uses-permission android:name="android.permission.USE_BIOMETRIC"/>
|
||||
</manifest>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -17,8 +17,6 @@ class WiFiUnavailableError extends Error {}
|
||||
|
||||
class SilentlyCancelUploadsError extends Error {}
|
||||
|
||||
class SharingNotPermittedForFreeAccountsError extends Error {}
|
||||
|
||||
class InvalidFileError extends ArgumentError {
|
||||
final InvalidReason reason;
|
||||
|
||||
|
||||
@@ -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("@"));
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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()),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -1,204 +0,0 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) 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. 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 `flutter analyze` with zero issues**
|
||||
- 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 `flutter analyze` passes 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
|
||||
|
||||
## 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/my-project-instructions.md
|
||||
@@ -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:
|
||||
|
||||
@@ -1 +1 @@
|
||||
ఫోటోలు,ఫోటోగ్రఫీ,కుటుంబం,గోప్యత,క్లౌడ్,బ్యాకప్,వీడియోలు,ఫోటో,సంకేతీకరణ,నిల్వ,ఆల్బమ్,ప్రత్యామ్నాయం
|
||||
photos,photography,family,privacy,cloud,backup,videos,photo,encryption,storage,album,alternative
|
||||
|
||||
@@ -1 +1 @@
|
||||
ఎంటే ఫోటోలు
|
||||
Ente Photos
|
||||
|
||||
@@ -1 +1 @@
|
||||
సంకేతీకరించిన ఫోటో నిల్వ
|
||||
Encrypted photo storage
|
||||
|
||||
@@ -29,6 +29,10 @@ class LRUMap<K, V> {
|
||||
}
|
||||
}
|
||||
|
||||
bool containsKey(K key) {
|
||||
return _map.containsKey(key);
|
||||
}
|
||||
|
||||
void remove(K key) {
|
||||
_map.remove(key);
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 = ':';
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
333
mobile/apps/photos/lib/db/local/db.dart
Normal file
333
mobile/apps/photos/lib/db/local/db.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
100
mobile/apps/photos/lib/db/local/mappers.dart
Normal file
100
mobile/apps/photos/lib/db/local/mappers.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
253
mobile/apps/photos/lib/db/local/schema.dart
Normal file
253
mobile/apps/photos/lib/db/local/schema.dart
Normal 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)",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
33
mobile/apps/photos/lib/db/local/table/device_albums.dart
Normal file
33
mobile/apps/photos/lib/db/local/table/device_albums.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
92
mobile/apps/photos/lib/db/local/table/path_config_table.dart
Normal file
92
mobile/apps/photos/lib/db/local/table/path_config_table.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
69
mobile/apps/photos/lib/db/local/table/shared_assets.dart
Normal file
69
mobile/apps/photos/lib/db/local/table/shared_assets.dart
Normal 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(),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
148
mobile/apps/photos/lib/db/local/table/upload_queue_table.dart
Normal file
148
mobile/apps/photos/lib/db/local/table/upload_queue_table.dart
Normal 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',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -53,13 +53,13 @@ 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,
|
||||
];
|
||||
@@ -80,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();
|
||||
|
||||
@@ -360,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) {
|
||||
@@ -411,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
|
||||
@@ -428,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);
|
||||
}
|
||||
@@ -726,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,
|
||||
@@ -738,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(
|
||||
@@ -758,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,
|
||||
@@ -1135,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);
|
||||
@@ -1335,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;
|
||||
@@ -1396,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) {
|
||||
@@ -1406,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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
1266
mobile/apps/photos/lib/db/ml/offlinedb.dart
Normal file
1266
mobile/apps/photos/lib/db/ml/offlinedb.dart
Normal file
File diff suppressed because it is too large
Load Diff
@@ -17,8 +17,9 @@ const personIdColumn = 'person_id';
|
||||
const clusterIDColumn = 'cluster_id';
|
||||
const personOrClusterIdColumn = 'person_or_cluster_id';
|
||||
|
||||
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,
|
||||
@@ -31,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
|
||||
@@ -98,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,
|
||||
|
||||
193
mobile/apps/photos/lib/db/remote/db.dart
Normal file
193
mobile/apps/photos/lib/db/remote/db.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
114
mobile/apps/photos/lib/db/remote/mappers.dart
Normal file
114
mobile/apps/photos/lib/db/remote/mappers.dart
Normal file
@@ -0,0 +1,114 @@
|
||||
import "dart:typed_data";
|
||||
|
||||
import "package:photos/models/api/diff/diff.dart";
|
||||
import "package:photos/models/api/diff/trash_time.dart";
|
||||
import "package:photos/models/file/file.dart";
|
||||
import "package:photos/models/file/remote/asset.dart";
|
||||
import "package:photos/models/file/remote/collection_file.dart";
|
||||
import "package:photos/models/file/remote/rl_mapping.dart";
|
||||
import "package:photos/models/location/location.dart";
|
||||
|
||||
RemoteAsset fromTrashRow(Map<String, dynamic> row) {
|
||||
final metadata = Metadata.fromEncodedJson(row['metadata']);
|
||||
final privateMetadata = Metadata.fromEncodedJson(row['priv_metadata']);
|
||||
final publicMetadata = Metadata.fromEncodedJson(row['pub_metadata']);
|
||||
final info = Info.fromEncodedJson(row['info']);
|
||||
return RemoteAsset.fromMetadata(
|
||||
id: row['id'],
|
||||
ownerID: row['owner_id'],
|
||||
thumbHeader: row['thumb_header'],
|
||||
fileHeader: row['file_header'],
|
||||
metadata: metadata!,
|
||||
privateMetadata: privateMetadata,
|
||||
publicMetadata: publicMetadata,
|
||||
info: info,
|
||||
);
|
||||
}
|
||||
|
||||
List<Object?> remoteAssetToRow(RemoteAsset asset) {
|
||||
return [
|
||||
asset.id,
|
||||
asset.ownerID,
|
||||
asset.fileHeader,
|
||||
asset.thumbHeader,
|
||||
asset.creationTime,
|
||||
asset.modificationTime,
|
||||
asset.type,
|
||||
asset.subType,
|
||||
asset.title,
|
||||
asset.fileSize,
|
||||
asset.hash,
|
||||
asset.visibility,
|
||||
asset.durationInSec,
|
||||
asset.location?.latitude,
|
||||
asset.location?.longitude,
|
||||
asset.height,
|
||||
asset.width,
|
||||
asset.noThumb,
|
||||
asset.sv,
|
||||
asset.mediaType,
|
||||
asset.motionVideoIndex,
|
||||
asset.caption,
|
||||
asset.uploaderName,
|
||||
];
|
||||
}
|
||||
|
||||
RemoteAsset fromFilesRow(Map<String, Object?> row) {
|
||||
return RemoteAsset(
|
||||
id: row['id'] as int,
|
||||
ownerID: row['owner_id'] as int,
|
||||
thumbHeader: row['thumb_header'] as Uint8List,
|
||||
fileHeader: row['file_header'] as Uint8List,
|
||||
creationTime: row['creation_time'] as int,
|
||||
modificationTime: row['modification_time'] as int,
|
||||
type: row['type'] as int,
|
||||
subType: row['subtype'] as int,
|
||||
title: row['title'] as String,
|
||||
fileSize: row['size'] as int?,
|
||||
hash: row['hash'] as String?,
|
||||
visibility: row['visibility'] as int?,
|
||||
durationInSec: row['durationInSec'] as int?,
|
||||
location: Location(
|
||||
latitude: (row['lat'] as num?)?.toDouble(),
|
||||
longitude: (row['lng'] as num?)?.toDouble(),
|
||||
),
|
||||
height: row['height'] as int?,
|
||||
width: row['width'] as int?,
|
||||
noThumb: row['no_thumb'] as int?,
|
||||
sv: row['sv'] as int?,
|
||||
mediaType: row['media_type'] as int?,
|
||||
motionVideoIndex: row['motion_video_index'] as int?,
|
||||
caption: row['caption'] as String?,
|
||||
uploaderName: row['uploader_name'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
RLMapping rowToUploadLocalMapping(Map<String, Object?> row) {
|
||||
return RLMapping(
|
||||
remoteUploadID: row['file_id'] as int,
|
||||
localID: row['local_id'] as String,
|
||||
localCloudID: row['local_cloud_id'] as String?,
|
||||
mappingType:
|
||||
MappingTypeExtension.fromName(row['local_mapping_src'] as String),
|
||||
);
|
||||
}
|
||||
|
||||
EnteFile trashRowToEnteFile(Map<String, Object?> row) {
|
||||
final RemoteAsset asset = fromTrashRow(row);
|
||||
final TrashTime time = TrashTime(
|
||||
createdAt: row['created_at'] as int,
|
||||
updatedAt: row['updated_at'] as int,
|
||||
deleteBy: row['delete_by'] as int,
|
||||
);
|
||||
final cf = CollectionFile(
|
||||
fileID: asset.id,
|
||||
collectionID: row['collection_id'] as int,
|
||||
encFileKey: row['enc_key'] as Uint8List,
|
||||
encFileKeyNonce: row['enc_key_nonce'] as Uint8List,
|
||||
updatedAt: time.updatedAt,
|
||||
createdAt: time.createdAt,
|
||||
);
|
||||
final file = EnteFile.fromRemoteAsset(asset, cf);
|
||||
file.trashTime = time;
|
||||
return file;
|
||||
}
|
||||
235
mobile/apps/photos/lib/db/remote/schema.dart
Normal file
235
mobile/apps/photos/lib/db/remote/schema.dart
Normal file
@@ -0,0 +1,235 @@
|
||||
const collectionColumns =
|
||||
'id, owner, enc_key, enc_key_nonce, name, type, local_path, is_deleted, '
|
||||
'updation_time, sharees, public_urls, mmd_encoded_json, '
|
||||
'mmd_ver, pub_mmd_encoded_json, pub_mmd_ver, shared_mmd_json, '
|
||||
'shared_mmd_ver';
|
||||
|
||||
final String updateCollectionColumns = collectionColumns
|
||||
.split(', ')
|
||||
.where((column) => column != 'id') // Exclude primary key from update
|
||||
.map((column) => '$column = excluded.$column') // Use excluded virtual table
|
||||
.join(', ');
|
||||
|
||||
const collectionFilesColumns =
|
||||
'collection_id, file_id, enc_key, enc_key_nonce, created_at, updated_at';
|
||||
|
||||
final String collectionFilesUpdateColumns = collectionFilesColumns
|
||||
.split(', ')
|
||||
.where(
|
||||
(column) =>
|
||||
column != 'collection_id' ||
|
||||
column != 'file_id' ||
|
||||
column != 'created_at',
|
||||
)
|
||||
.map((column) => '$column = excluded.$column') // Use excluded virtual table
|
||||
.join(', ');
|
||||
|
||||
const filesColumns =
|
||||
'id, owner_id, file_header, thumb_header, creation_time, modification_time, '
|
||||
'type, subtype, title, size, hash, visibility, durationInSec, lat, lng, '
|
||||
'height, width, no_thumb, sv, media_type, motion_video_index, caption, uploader_name';
|
||||
|
||||
final String filesUpdateColumns = filesColumns
|
||||
.split(', ')
|
||||
.where((column) => (column != 'id'))
|
||||
.map((column) => '$column = excluded.$column') // Use excluded virtual table
|
||||
.join(', ');
|
||||
|
||||
const filesMetadataColumns = 'id, metadata, priv_metadata, pub_metadata, info';
|
||||
final String filesMetadataUpdateColumns = filesMetadataColumns
|
||||
.split(', ')
|
||||
.where((column) => (column != 'id'))
|
||||
.map((column) => '$column = excluded.$column') // Use excluded virtual table
|
||||
.join(', ');
|
||||
|
||||
const trashedFilesColumns =
|
||||
'id, owner_id, collection_id, enc_key,enc_key_nonce, file_header, thumb_header, metadata, priv_metadata, pub_metadata, info, created_at, updated_at, delete_by';
|
||||
|
||||
final String trashedFilesUpdateColumns = trashedFilesColumns
|
||||
.split(', ')
|
||||
.where((column) => (column != 'id'))
|
||||
.map((column) => '$column = excluded.$column') // Use excluded virtual table
|
||||
.join(', ');
|
||||
|
||||
const uploadLocalMappingColumns =
|
||||
'file_id, local_id, local_cloud_id, local_mapping_src';
|
||||
String collectionValuePlaceHolder =
|
||||
collectionColumns.split(',').map((_) => '?').join(',');
|
||||
|
||||
class RemoteDBMigration {
|
||||
static const migrationScripts = [
|
||||
'''
|
||||
CREATE TABLE collections (
|
||||
id INTEGER PRIMARY KEY,
|
||||
owner TEXT NOT NULL,
|
||||
enc_key TEXT NOT NULL,
|
||||
enc_key_nonce TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
local_path TEXT,
|
||||
is_deleted INTEGER NOT NULL,
|
||||
updation_time INTEGER NOT NULL,
|
||||
sharees TEXT NOT NULL DEFAULT '[]',
|
||||
public_urls TEXT NOT NULL DEFAULT '[]',
|
||||
mmd_encoded_json TEXT NOT NULL DEFAULT '{}',
|
||||
mmd_ver INTEGER NOT NULL DEFAULT 0,
|
||||
pub_mmd_encoded_json TEXT DEFAULT '{}',
|
||||
pub_mmd_ver INTEGER NOT NULL DEFAULT 0,
|
||||
shared_mmd_json TEXT NOT NULL DEFAULT '{}',
|
||||
shared_mmd_ver INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
''',
|
||||
'''
|
||||
CREATE TABLE collection_files (
|
||||
file_id INTEGER NOT NULL,
|
||||
collection_id INTEGER NOT NULL,
|
||||
enc_key BLOB NOT NULL,
|
||||
enc_key_nonce BLOB NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (file_id, collection_id)
|
||||
);
|
||||
''',
|
||||
'''
|
||||
CREATE TABLE files (
|
||||
id INTEGER PRIMARY KEY,
|
||||
owner_id INTEGER NOT NULL,
|
||||
file_header BLOB NOT NULL,
|
||||
thumb_header BLOB NOT NULL,
|
||||
creation_time INTEGER NOT NULL,
|
||||
modification_time INTEGER NOT NULL,
|
||||
type INTEGER NOT NULL,
|
||||
subtype INTEGER NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
size INTEGER,
|
||||
hash TEXT,
|
||||
visibility integer,
|
||||
durationInSec INTEGER,
|
||||
lat REAL DEFAULT NULL,
|
||||
lng REAL DEFAULT NULL,
|
||||
height INTEGER,
|
||||
width INTEGER,
|
||||
no_thumb INTEGER,
|
||||
sv INTEGER,
|
||||
media_type INTEGER,
|
||||
motion_video_index INTEGER,
|
||||
caption TEXT,
|
||||
uploader_name TEXT
|
||||
)
|
||||
''',
|
||||
'''
|
||||
CREATE INDEX IF NOT EXISTS file_hash_index ON files(hash);
|
||||
''',
|
||||
'''
|
||||
CREATE INDEX IF NOT EXISTS file_creation_time_index ON files(creation_time);
|
||||
''',
|
||||
'''
|
||||
CREATE TABLE files_metadata (
|
||||
id INTEGER PRIMARY KEY,
|
||||
metadata TEXT NOT NULL,
|
||||
priv_metadata TEXT,
|
||||
pub_metadata TEXT,
|
||||
info TEXT,
|
||||
FOREIGN KEY (id) REFERENCES files(id) ON DELETE CASCADE
|
||||
)
|
||||
''',
|
||||
'''
|
||||
CREATE TABLE trash (
|
||||
id INTEGER PRIMARY KEY,
|
||||
owner_id INTEGER NOT NULL,
|
||||
collection_id INTEGER NOT NULL,
|
||||
enc_key BLOB NOT NULL,
|
||||
enc_key_nonce BLOB NOT NULL,
|
||||
metadata TEXT NOT NULL,
|
||||
priv_metadata TEXT,
|
||||
pub_metadata TEXT,
|
||||
info TEXT,
|
||||
file_header BLOB NOT NULL,
|
||||
thumb_header BLOB NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
delete_by INTEGER NOT NULL
|
||||
)
|
||||
''',
|
||||
'''
|
||||
CREATE TRIGGER delete_orphaned_files
|
||||
AFTER DELETE ON collection_files
|
||||
FOR EACH ROW
|
||||
WHEN (
|
||||
-- Only proceed if this file_id actually existed before deletion
|
||||
OLD.file_id IS NOT NULL
|
||||
-- And only if this was the last reference to the file
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM collection_files
|
||||
WHERE file_id = OLD.file_id
|
||||
)
|
||||
)
|
||||
BEGIN
|
||||
-- Only then delete from files table
|
||||
DELETE FROM files WHERE id = OLD.file_id;
|
||||
END;
|
||||
''',
|
||||
'''
|
||||
CREATE TABLE upload_mapping (
|
||||
file_id INTEGER PRIMARY KEY,
|
||||
local_id TEXT NOT NULL,
|
||||
-- icloud identifier if available
|
||||
local_cloud_id TEXT,
|
||||
local_mapping_src TEXT DEFAULT NULL,
|
||||
FOREIGN KEY (file_id) REFERENCES files(id) ON DELETE CASCADE
|
||||
)'''
|
||||
];
|
||||
}
|
||||
|
||||
class FilterQueryParam {
|
||||
int? collectionID;
|
||||
int? limit;
|
||||
int? offset;
|
||||
String? orderByColumn;
|
||||
bool isAsc;
|
||||
(int?, int?)? createAtRange;
|
||||
|
||||
FilterQueryParam({
|
||||
this.limit,
|
||||
this.offset,
|
||||
this.collectionID,
|
||||
this.orderByColumn = "creation_time",
|
||||
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 collectionFilter =>
|
||||
(collectionID == null) ? "" : "collection_id = $collectionID";
|
||||
|
||||
String get createAtRangeStr => (createAtRange == null ||
|
||||
createAtRange!.$1 == null)
|
||||
? ""
|
||||
: "(creation_time BETWEEN ${createAtRange!.$1} AND ${createAtRange!.$2})";
|
||||
|
||||
String whereClause() {
|
||||
final where = <String>[];
|
||||
if (collectionFilter.isNotEmpty) {
|
||||
where.add(collectionFilter);
|
||||
}
|
||||
if (createAtRangeStr.isNotEmpty) {
|
||||
where.add(createAtRangeStr);
|
||||
}
|
||||
|
||||
return (where.isEmpty ? "" : where.join(" AND ")) +
|
||||
" " +
|
||||
orderBy +
|
||||
" " +
|
||||
limitOffset;
|
||||
}
|
||||
}
|
||||
288
mobile/apps/photos/lib/db/remote/table/collection_files.dart
Normal file
288
mobile/apps/photos/lib/db/remote/table/collection_files.dart
Normal file
@@ -0,0 +1,288 @@
|
||||
import "package:flutter/foundation.dart";
|
||||
import "package:photos/db/remote/db.dart";
|
||||
import "package:photos/db/remote/schema.dart";
|
||||
import "package:photos/extensions/stop_watch.dart";
|
||||
import "package:photos/models/file/remote/collection_file.dart";
|
||||
|
||||
extension CollectionFiles on RemoteDB {
|
||||
Future<int> getCollectionFileCount(int collectionID) async {
|
||||
final row = await sqliteDB.get(
|
||||
"SELECT COUNT(*) as count FROM collection_files WHERE collection_id = ?",
|
||||
[collectionID],
|
||||
);
|
||||
return row["count"] as int;
|
||||
}
|
||||
|
||||
Future<Set<int>> getUploadedFileIDs(int collectionID) async {
|
||||
final rows = await sqliteDB.getAll(
|
||||
"SELECT file_id FROM collection_files WHERE collection_id = ?",
|
||||
[collectionID],
|
||||
);
|
||||
final Set<int> fileIDs = {};
|
||||
for (var row in rows) {
|
||||
fileIDs.add(row["file_id"] as int);
|
||||
}
|
||||
return fileIDs;
|
||||
}
|
||||
|
||||
Future<Set<int>> getAllCollectionIDsOfFile(int fileID) async {
|
||||
final rows = await sqliteDB.getAll(
|
||||
"SELECT collection_id FROM collection_files WHERE file_id = ?",
|
||||
[fileID],
|
||||
);
|
||||
final Set<int> collectionIDs = {};
|
||||
for (var row in rows) {
|
||||
collectionIDs.add(row["collection_id"] as int);
|
||||
}
|
||||
return collectionIDs;
|
||||
}
|
||||
|
||||
Future<Map<int, List<CollectionFile>>> getCollectionFilesGroupedByCollection(
|
||||
List<int> fileIDs,
|
||||
) async {
|
||||
final result = <int, List<CollectionFile>>{};
|
||||
if (fileIDs.isEmpty) {
|
||||
return result;
|
||||
}
|
||||
final inParam = fileIDs.map((id) => "'$id'").join(',');
|
||||
final results = await sqliteDB.getAll(
|
||||
'SELECT * FROM collection_files WHERE file_id IN ($inParam)',
|
||||
);
|
||||
for (final row in results) {
|
||||
final eachFile = CollectionFile.fromMap(row);
|
||||
if (!result.containsKey(eachFile.collectionID)) {
|
||||
result[eachFile.collectionID] = <CollectionFile>[];
|
||||
}
|
||||
result[eachFile.collectionID]!.add(eachFile);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<List<CollectionFile>> getAllCFForFileIDs(
|
||||
List<int> fileIDs,
|
||||
) async {
|
||||
if (fileIDs.isEmpty) return [];
|
||||
final rows = await sqliteDB.getAll(
|
||||
"SELECT * FROM collection_files WHERE file_id IN (${fileIDs.join(",")})",
|
||||
);
|
||||
return rows
|
||||
.map((row) => CollectionFile.fromMap(row))
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
Future<Map<int, int>> getCollectionIdToFileCount(List<int> fileIDs) async {
|
||||
final rows = await sqliteDB.getAll(
|
||||
"SELECT collection_id, COUNT(*) as count FROM collection_files WHERE file_id IN (${fileIDs.join(",")}) GROUP BY collection_id",
|
||||
);
|
||||
final Map<int, int> collectionIdToFileCount = {};
|
||||
for (var row in rows) {
|
||||
final collectionId = row["collection_id"] as int;
|
||||
final count = row["count"] as int;
|
||||
collectionIdToFileCount[collectionId] = count;
|
||||
}
|
||||
return collectionIdToFileCount;
|
||||
}
|
||||
|
||||
Future<List<CollectionFile>> getCollectionFiles(
|
||||
FilterQueryParam? params,
|
||||
) async {
|
||||
final rows = await sqliteDB.getAll(
|
||||
"SELECT collection_files.* FROM collection_files JOIN files on collection_files.file_id=files.id WHERE ${params?.whereClause() ?? "order by creation_time desc"}",
|
||||
);
|
||||
return rows
|
||||
.map((row) => CollectionFile.fromMap(row))
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
Future<List<CollectionFile>> getCollectionsFiles(
|
||||
Set<int> collectionIDs,
|
||||
) async {
|
||||
final rows = await sqliteDB.getAll(
|
||||
"SELECT collection_files.* FROM collection_files JOIN files on collection_files.file_id=files.id WHERE collection_id IN (${collectionIDs.join(",")}) ORDER BY creation_time DESC",
|
||||
);
|
||||
return rows
|
||||
.map((row) => CollectionFile.fromMap(row))
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
Future<Map<int, CollectionFile>> getFileIdToCollectionFile(
|
||||
List<int> fileIDs,
|
||||
) async {
|
||||
final rows = await sqliteDB.getAll(
|
||||
"SELECT collection_files.* FROM collection_files JOIN files on collection_files.file_id=files.id WHERE file_id IN (${fileIDs.join(",")})",
|
||||
);
|
||||
final Map<int, CollectionFile> result = {};
|
||||
for (var row in rows) {
|
||||
final entry = CollectionFile.fromMap(row);
|
||||
result[entry.fileID] = entry;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<List<CollectionFile>> getAllFiles(int userID) {
|
||||
return sqliteDB.getAll(
|
||||
"SELECT collection_files.* FROM collection_files JOIN files ON collection_files.file_id = files.id WHERE files.owner_id = ? ORDER BY files.creation_time DESC",
|
||||
[userID],
|
||||
).then(
|
||||
(rows) => rows.map((row) => CollectionFile.fromMap(row)).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<(Set<int>, Map<String, int>)> getUploadAndHash(
|
||||
int collectionID,
|
||||
) async {
|
||||
final results = await sqliteDB.getAll(
|
||||
'SELECT id, hash FROM collection_files JOIN files ON files.id = collection_files.file_id'
|
||||
' WHERE collection_id = ?',
|
||||
[
|
||||
collectionID,
|
||||
],
|
||||
);
|
||||
final ids = <int>{};
|
||||
final hash = <String, int>{};
|
||||
for (final result in results) {
|
||||
ids.add(result['id'] as int);
|
||||
if (result['hash'] != null) {
|
||||
hash[result['hash'] as String] = result['id'] as int;
|
||||
}
|
||||
}
|
||||
return (ids, hash);
|
||||
}
|
||||
|
||||
Future<List<CollectionFile>> ownedFilesWithSameHash(
|
||||
List<String> hashes,
|
||||
int ownerID,
|
||||
) async {
|
||||
if (hashes.isEmpty) return [];
|
||||
final inParam = hashes.map((e) => "'$e'").join(',');
|
||||
final rows = await sqliteDB.getAll(
|
||||
"SELECT collection_files.* FROM collection_files JOIN files ON collection_files.file_id = files.id WHERE files.hash IN ($inParam) AND files.owner_id = ?",
|
||||
[ownerID],
|
||||
);
|
||||
return rows
|
||||
.map((row) => CollectionFile.fromMap(row))
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
Future<CollectionFile?> coverFile(
|
||||
int collectionID,
|
||||
int? fileID, {
|
||||
bool sortInAsc = false,
|
||||
}) async {
|
||||
if (fileID != null) {
|
||||
final entry = await getCollectionFileEntry(collectionID, fileID);
|
||||
if (entry != null) {
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
final sortedRow = await sqliteDB.getOptional(
|
||||
"SELECT collection_files.* FROM collection_files join files on files.id= collection_files.file_id WHERE collection_id = ? ORDER BY files.creation_time ${sortInAsc ? 'ASC' : 'DESC'} LIMIT 1",
|
||||
[collectionID],
|
||||
);
|
||||
if (sortedRow != null) {
|
||||
return CollectionFile.fromMap(sortedRow);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<CollectionFile?> getCollectionFileEntry(
|
||||
int collectionID,
|
||||
int fileID,
|
||||
) async {
|
||||
final row = await sqliteDB.getOptional(
|
||||
"SELECT * FROM collection_files WHERE collection_id = ? AND file_id = ?",
|
||||
[collectionID, fileID],
|
||||
);
|
||||
if (row != null) {
|
||||
return CollectionFile.fromMap(row);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<CollectionFile?> getAnyCollectionEntry(
|
||||
int fileID,
|
||||
) async {
|
||||
final row = await sqliteDB.getAll(
|
||||
"SELECT * FROM collection_files WHERE file_id = ? limit 1",
|
||||
[fileID],
|
||||
);
|
||||
if (row.isNotEmpty) {
|
||||
return CollectionFile.fromMap(row.first);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<List<CollectionFile>> getFilesCreatedWithinDurations(
|
||||
List<List<int>> durations,
|
||||
Set<int> ignoredCollectionIDs, {
|
||||
String order = 'DESC',
|
||||
}) async {
|
||||
final List<CollectionFile> result = [];
|
||||
for (final duration in durations) {
|
||||
final start = duration[0];
|
||||
final end = duration[1];
|
||||
final rows = await sqliteDB.getAll(
|
||||
"SELECT collection_files.* FROM collection_files join files on files.id=collection_files.file_id WHERE files.creation_time BETWEEN ? AND ? AND collection_id NOT IN (${ignoredCollectionIDs.join(",")}) ORDER BY creation_time $order",
|
||||
[start, end],
|
||||
);
|
||||
result.addAll(rows.map((row) => CollectionFile.fromMap(row)));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<List<CollectionFile>> filesWithLocation() {
|
||||
return sqliteDB
|
||||
.getAll(
|
||||
"SELECT collection_files.* FROM collection_files JOIN files ON collection_files.file_id = files.id WHERE files.lat IS NOT NULL and files.lng IS NOT NULL order by files.creation_time desc",
|
||||
)
|
||||
.then(
|
||||
(rows) => rows.map((row) => CollectionFile.fromMap(row)).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> deleteFiles(List<int> fileIDs) async {
|
||||
if (fileIDs.isEmpty) return;
|
||||
final stopwatch = Stopwatch()..start();
|
||||
await sqliteDB.execute(
|
||||
"DELETE FROM collection_files WHERE file_id IN (${fileIDs.join(",")})",
|
||||
);
|
||||
debugPrint(
|
||||
'$runtimeType deleteFiles complete in ${stopwatch.elapsed.inMilliseconds}ms for ${fileIDs.length}',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> deleteCollectionFiles(List<int> cIDs) async {
|
||||
if (cIDs.isEmpty) return;
|
||||
await sqliteDB.execute(
|
||||
"DELETE FROM collection_files WHERE collection_id IN (${cIDs.join(",")})",
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> deleteCFEnteries(
|
||||
int collectionID,
|
||||
List<int> fileIDs,
|
||||
) async {
|
||||
if (fileIDs.isEmpty) return;
|
||||
await sqliteDB.execute(
|
||||
"DELETE FROM collection_files WHERE collection_id = ? AND file_id IN (${fileIDs.join(",")})",
|
||||
[collectionID],
|
||||
);
|
||||
}
|
||||
|
||||
Future<Map<int, int>> getCollectionIDToMaxCreationTime() async {
|
||||
final enteWatch = EnteWatch("getCollectionIDToMaxCreationTime")..start();
|
||||
final rows = await sqliteDB.getAll(
|
||||
'''SELECT collection_id, MAX(creation_time) as max_creation_time FROM collection_files join files on
|
||||
collection_files.file_id=files.id GROUP BY collection_id''',
|
||||
);
|
||||
final Map<int, int> result = {};
|
||||
for (var row in rows) {
|
||||
final collectionId = row["collection_id"] as int;
|
||||
final maxCreationTime = row["max_creation_time"] as int;
|
||||
result[collectionId] = maxCreationTime;
|
||||
}
|
||||
enteWatch.log("query done");
|
||||
return result;
|
||||
}
|
||||
}
|
||||
156
mobile/apps/photos/lib/db/remote/table/files_table.dart
Normal file
156
mobile/apps/photos/lib/db/remote/table/files_table.dart
Normal file
@@ -0,0 +1,156 @@
|
||||
import "package:photos/db/remote/db.dart";
|
||||
import "package:photos/models/api/diff/diff.dart";
|
||||
import "package:photos/models/file/file_type.dart";
|
||||
|
||||
extension FilesTable on RemoteDB {
|
||||
// For a given userID, return unique uploadedFileId for the given userID
|
||||
Future<List<int>> fileIDsWithMissingSize(int userId) async {
|
||||
final rows = await sqliteDB.getAll(
|
||||
"SELECT id FROM files WHERE owner_id = ? AND size = -1",
|
||||
[userId],
|
||||
);
|
||||
final result = <int>[];
|
||||
for (final row in rows) {
|
||||
result.add(row['id'] as int);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<Map<int, int>> getIDToCreationTime() async {
|
||||
final rows = await sqliteDB.getAll(
|
||||
"SELECT id, creation_time FROM files",
|
||||
);
|
||||
final result = <int, int>{};
|
||||
for (final row in rows) {
|
||||
result[row['id'] as int] = row['creation_time'] as int;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<Map<int, Metadata?>> getIDToMetadata(
|
||||
Set<int> ids, {
|
||||
bool private = false,
|
||||
bool public = false,
|
||||
bool metadata = false,
|
||||
}) async {
|
||||
if (ids.isEmpty) return {};
|
||||
|
||||
// Ensure only one parameter is true
|
||||
final trueCount = [private, public, metadata].where((x) => x).length;
|
||||
if (trueCount != 1) {
|
||||
throw ArgumentError(
|
||||
'Exactly one of private, public, or metadata must be true',
|
||||
);
|
||||
}
|
||||
|
||||
final placeholders = List.filled(ids.length, '?').join(',');
|
||||
String column;
|
||||
|
||||
if (private) {
|
||||
column = 'priv_metadata';
|
||||
} else if (public) {
|
||||
column = 'pub_metadata';
|
||||
} else {
|
||||
column = 'metadata';
|
||||
}
|
||||
final rows = await sqliteDB.getAll(
|
||||
"SELECT id, $column FROM files_metadata WHERE id IN ($placeholders)",
|
||||
ids.toList(),
|
||||
);
|
||||
final result = <int, Metadata?>{};
|
||||
for (final row in rows) {
|
||||
final metadata = Metadata.fromEncodedJson(row[column]);
|
||||
result[row['id'] as int] = metadata;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<Set<int>> idsWithSameHashAndType(String hash, int ownerID) {
|
||||
return sqliteDB.getAll(
|
||||
"SELECT id FROM files WHERE hash = ? AND owner_id = ?",
|
||||
[hash, ownerID],
|
||||
).then((rows) {
|
||||
final result = <int>{};
|
||||
for (final row in rows) {
|
||||
result.add(row['id'] as int);
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
// updateSizeForUploadIDs takes a map of upploadedFileID and fileSize and
|
||||
// update the fileSize for the given uploadedFileID
|
||||
Future<void> updateSize(
|
||||
Map<int, int> idToSize,
|
||||
) async {
|
||||
final parameterSets = <List<Object?>>[];
|
||||
for (final id in idToSize.keys) {
|
||||
parameterSets.add([idToSize[id], id]);
|
||||
}
|
||||
return sqliteDB.executeBatch(
|
||||
"UPDATE files SET size = ? WHERE id = ?;",
|
||||
parameterSets,
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<int>> getAllFilesAfterDate({
|
||||
required FileType fileType,
|
||||
required DateTime beginDate,
|
||||
required int userID,
|
||||
}) async {
|
||||
final results = await sqliteDB.getAll(
|
||||
'''
|
||||
SELECT files.id FROM files join upload_mapping
|
||||
ON files.id = upload_mapping.file_id
|
||||
WHERE file_type = ?
|
||||
AND creation_time > ?
|
||||
AND owner_id = ?
|
||||
AND (size IS NOT NULL AND size <= 524288000)
|
||||
AND (durationInSec IS NOT NULL AND (durationInSec <= 60 AND durationInSec > 0))
|
||||
''',
|
||||
[getInt(fileType), beginDate.microsecondsSinceEpoch, userID],
|
||||
);
|
||||
final fileIDs = <int>[];
|
||||
for (final row in results) {
|
||||
fileIDs.add(row['id'] as int);
|
||||
}
|
||||
return fileIDs;
|
||||
}
|
||||
|
||||
Future<Map<int, List<(int, Metadata?)>>> getNotificationCandidate(
|
||||
List<int> collectionIDs,
|
||||
int lastAppOpen,
|
||||
) async {
|
||||
if (collectionIDs.isEmpty) return {};
|
||||
final placeholders = List.filled(collectionIDs.length, '?').join(',');
|
||||
final rows = await sqliteDB.getAll(
|
||||
"SELECT collection_id, files.owner_id, metadata FROM collection_files join files ON collection_files.file_id = files.id WHERE collection_id IN ($placeholders) AND collection_files.created_at > ?",
|
||||
[...collectionIDs, lastAppOpen],
|
||||
);
|
||||
final result = <int, List<(int, Metadata?)>>{};
|
||||
for (final row in rows) {
|
||||
final collectionID = row['collection_id'] as int;
|
||||
final ownerID = row['owner_id'] as int;
|
||||
final metadata = Metadata.fromEncodedJson(row['metadata']);
|
||||
result.putIfAbsent(collectionID, () => []).add((ownerID, metadata));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<int> getFilesCountByVisibility(
|
||||
int visibility,
|
||||
int ownerID,
|
||||
Set<int> hiddenCollections,
|
||||
) async {
|
||||
String subQuery = '';
|
||||
if (hiddenCollections.isNotEmpty) {
|
||||
subQuery =
|
||||
'AND id NOT IN (SELECT file_id FROM collection_files WHERE collection_id IN (${hiddenCollections.join(',')}))';
|
||||
}
|
||||
final row = await sqliteDB.get(
|
||||
'SELECT COUNT(id) as count FROM files WHERE visibility = ? AND owner_id = ? $subQuery',
|
||||
[visibility, ownerID],
|
||||
);
|
||||
return row['count'] as int;
|
||||
}
|
||||
}
|
||||
119
mobile/apps/photos/lib/db/remote/table/mapping_table.dart
Normal file
119
mobile/apps/photos/lib/db/remote/table/mapping_table.dart
Normal file
@@ -0,0 +1,119 @@
|
||||
import "package:collection/collection.dart";
|
||||
import "package:flutter/foundation.dart";
|
||||
import "package:photos/db/remote/db.dart";
|
||||
import "package:photos/db/remote/mappers.dart";
|
||||
import "package:photos/db/remote/schema.dart";
|
||||
import "package:photos/models/backup_status.dart";
|
||||
import "package:photos/models/file/remote/rl_mapping.dart";
|
||||
|
||||
extension UploadMappingTable on RemoteDB {
|
||||
Future<void> insertMappings(List<RLMapping> mappings) async {
|
||||
if (mappings.isEmpty) return;
|
||||
final stopwatch = Stopwatch()..start();
|
||||
await Future.forEach(mappings.slices(1000), (slice) async {
|
||||
final List<List<Object?>> values = slice.map((e) => e.rowValues).toList();
|
||||
await sqliteDB.executeBatch(
|
||||
'INSERT INTO upload_mapping ($uploadLocalMappingColumns) values(?,?,?,?)',
|
||||
values,
|
||||
);
|
||||
});
|
||||
debugPrint(
|
||||
'$runtimeType insertMappings complete in ${stopwatch.elapsed.inMilliseconds}ms for ${mappings.length} mappings',
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<RLMapping>> getMappings() async {
|
||||
final result = <RLMapping>[];
|
||||
final cursor = await sqliteDB.getAll("SELECT * FROM upload_mapping");
|
||||
for (final row in cursor) {
|
||||
result.add(rowToUploadLocalMapping(row));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<void> deleteMappingsForLocalIDs(Set<String> localIDs) async {
|
||||
if (localIDs.isEmpty) return;
|
||||
final placeholders = List.filled(localIDs.length, '?').join(',');
|
||||
await sqliteDB.execute(
|
||||
'DELETE FROM upload_mapping WHERE local_id IN ($placeholders)',
|
||||
localIDs.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<Map<String, RLMapping>> getLocalIDToMappingForActiveFiles() async {
|
||||
final result = <String, RLMapping>{};
|
||||
final cursor = await sqliteDB.getAll(
|
||||
"SELECT * FROM upload_mapping join files on upload_mapping.file_id = files.id",
|
||||
);
|
||||
for (final row in cursor) {
|
||||
final mapping = rowToUploadLocalMapping(row);
|
||||
result[mapping.localID] = mapping;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// getLocalIDsForUser returns information about the localIDs that have been
|
||||
// uploaded for the given userID. If the localIDSInGivenPath is not null,
|
||||
// it will only return the localIDs that are in the given path.
|
||||
Future<BackedUpFileIDs> getLocalIDsForUser(
|
||||
int userID,
|
||||
Set<String>? localIDSInGivenPath,
|
||||
) async {
|
||||
final results = await sqliteDB.getAll(
|
||||
'SELECT local_id, files.id, size FROM upload_mapping join files on upload_mapping.file_id = files.id WHERE owner_id = ?',
|
||||
[userID],
|
||||
);
|
||||
|
||||
final Set<String> localIDs = <String>{};
|
||||
final Set<int> uploadedIDs = <int>{};
|
||||
int localSize = 0;
|
||||
for (final result in results) {
|
||||
final String localID = result['local_id'] as String;
|
||||
if (localIDSInGivenPath != null &&
|
||||
!localIDSInGivenPath.contains(localID)) {
|
||||
continue; // Skip if not in the given path
|
||||
}
|
||||
final int? fileSize = result['size'] as int?;
|
||||
if (!localIDs.contains(localID) && fileSize != null) {
|
||||
localSize += fileSize;
|
||||
}
|
||||
localIDs.add(localID);
|
||||
uploadedIDs.add(result['id'] as int);
|
||||
}
|
||||
return BackedUpFileIDs(localIDs.toList(), uploadedIDs.toList(), localSize);
|
||||
}
|
||||
|
||||
Future<Set<String>> getLocalIDsWithMapping(List<String> localIDs) async {
|
||||
if (localIDs.isEmpty) return {};
|
||||
final placeholders = List.filled(localIDs.length, '?').join(',');
|
||||
final cursor = await sqliteDB.getAll(
|
||||
'SELECT local_id FROM upload_mapping join files on upload_mapping.file_id = files.id WHERE local_id IN ($placeholders)',
|
||||
localIDs,
|
||||
);
|
||||
return cursor.map((row) => row['local_id'] as String).toSet();
|
||||
}
|
||||
|
||||
Future<Map<int, String>> getFileIDToLocalIDMapping(List<int> fileIDs) async {
|
||||
if (fileIDs.isEmpty) return {};
|
||||
final placeholders = List.filled(fileIDs.length, '?').join(',');
|
||||
final cursor = await sqliteDB.getAll(
|
||||
'SELECT file_id, local_id FROM upload_mapping WHERE file_id IN ($placeholders)',
|
||||
fileIDs,
|
||||
);
|
||||
return Map.fromEntries(
|
||||
cursor.map(
|
||||
(row) => MapEntry(row['file_id'] as int, row['local_id'] as String),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<Set<int>> getFilesWithMapping(List<int> fileIDs) async {
|
||||
if (fileIDs.isEmpty) return {};
|
||||
final placeholders = List.filled(fileIDs.length, '?').join(',');
|
||||
final cursor = await sqliteDB.getAll(
|
||||
'SELECT file_id FROM upload_mapping WHERE file_id IN ($placeholders)',
|
||||
fileIDs,
|
||||
);
|
||||
return cursor.map((row) => row['file_id'] as int).toSet();
|
||||
}
|
||||
}
|
||||
49
mobile/apps/photos/lib/db/remote/table/trash.dart
Normal file
49
mobile/apps/photos/lib/db/remote/table/trash.dart
Normal file
@@ -0,0 +1,49 @@
|
||||
import "package:collection/collection.dart";
|
||||
import "package:flutter/foundation.dart";
|
||||
import "package:photos/db/remote/db.dart";
|
||||
import "package:photos/db/remote/mappers.dart";
|
||||
import "package:photos/db/remote/schema.dart";
|
||||
import "package:photos/models/api/diff/diff.dart";
|
||||
import "package:photos/models/file/file.dart";
|
||||
|
||||
extension TrashTable on RemoteDB {
|
||||
Future<void> insertTrashDiffItems(List<DiffItem> items) async {
|
||||
if (items.isEmpty) return;
|
||||
final stopwatch = Stopwatch()..start();
|
||||
await Future.forEach(items.slices(1000), (slice) async {
|
||||
final List<List<Object?>> trashRowValues = [];
|
||||
for (final item in slice) {
|
||||
trashRowValues.add(item.trashRowValues());
|
||||
}
|
||||
await Future.wait([
|
||||
sqliteDB.executeBatch(
|
||||
'INSERT INTO trash ($trashedFilesColumns) values(${getParams(14)})',
|
||||
trashRowValues,
|
||||
),
|
||||
]);
|
||||
});
|
||||
debugPrint(
|
||||
'$runtimeType insertCollectionFilesDiff complete in ${stopwatch.elapsed.inMilliseconds}ms for ${items.length}',
|
||||
);
|
||||
}
|
||||
|
||||
// removes the items and returns the number of items removed
|
||||
Future<int> removeTrashItems(List<int> ids) async {
|
||||
if (ids.isEmpty) return 0;
|
||||
final result = await sqliteDB.execute(
|
||||
'DELETE FROM trash WHERE id IN (${ids.join(",")})',
|
||||
);
|
||||
return result.isNotEmpty ? result.first['changes'] as int : 0;
|
||||
}
|
||||
|
||||
Future<List<EnteFile>> getTrashFiles() async {
|
||||
final result = await sqliteDB.getAll(
|
||||
'SELECT * FROM trash',
|
||||
);
|
||||
return result.map((e) => trashRowToEnteFile(e)).toList();
|
||||
}
|
||||
|
||||
Future<void> clearTrash() async {
|
||||
await sqliteDB.execute('DELETE FROM trash');
|
||||
}
|
||||
}
|
||||
@@ -1,250 +0,0 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:path/path.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:photos/models/file/trash_file.dart';
|
||||
import 'package:photos/models/file_load_result.dart';
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
|
||||
// The TrashDB doesn't need to flatten and store all attributes of a file.
|
||||
// Before adding any other column, we should evaluate if we need to query on that
|
||||
// column or not while showing trashed items. Even if we miss storing any new attributes,
|
||||
// during restore, all file attributes will be fetched & stored as required.
|
||||
class TrashDB {
|
||||
static const _databaseName = "ente.trash.db";
|
||||
static const _databaseVersion = 1;
|
||||
static final Logger _logger = Logger("TrashDB");
|
||||
static const tableName = 'trash';
|
||||
|
||||
static const columnUploadedFileID = 'uploaded_file_id';
|
||||
static const columnCollectionID = 'collection_id';
|
||||
static const columnOwnerID = 'owner_id';
|
||||
static const columnTrashUpdatedAt = 't_updated_at';
|
||||
static const columnTrashDeleteBy = 't_delete_by';
|
||||
static const columnEncryptedKey = 'encrypted_key';
|
||||
static const columnKeyDecryptionNonce = 'key_decryption_nonce';
|
||||
static const columnFileDecryptionHeader = 'file_decryption_header';
|
||||
static const columnThumbnailDecryptionHeader = 'thumbnail_decryption_header';
|
||||
static const columnUpdationTime = 'updation_time';
|
||||
|
||||
static const columnCreationTime = 'creation_time';
|
||||
static const columnLocalID = 'local_id';
|
||||
|
||||
// standard file metadata, which isn't editable
|
||||
static const columnFileMetadata = 'file_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';
|
||||
|
||||
Future _onCreate(Database db, int version) async {
|
||||
await db.execute(
|
||||
'''
|
||||
CREATE TABLE $tableName (
|
||||
$columnUploadedFileID INTEGER PRIMARY KEY NOT NULL,
|
||||
$columnCollectionID INTEGER NOT NULL,
|
||||
$columnOwnerID INTEGER,
|
||||
$columnTrashUpdatedAt INTEGER NOT NULL,
|
||||
$columnTrashDeleteBy INTEGER NOT NULL,
|
||||
$columnEncryptedKey TEXT,
|
||||
$columnKeyDecryptionNonce TEXT,
|
||||
$columnFileDecryptionHeader TEXT,
|
||||
$columnThumbnailDecryptionHeader TEXT,
|
||||
$columnUpdationTime INTEGER,
|
||||
$columnLocalID TEXT,
|
||||
$columnCreationTime INTEGER NOT NULL,
|
||||
$columnFileMetadata TEXT DEFAULT '{}',
|
||||
$columnMMdEncodedJson TEXT DEFAULT '{}',
|
||||
$columnMMdVersion INTEGER DEFAULT 0,
|
||||
$columnPubMMdEncodedJson TEXT DEFAULT '{}',
|
||||
$columnPubMMdVersion INTEGER DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS creation_time_index ON $tableName($columnCreationTime);
|
||||
CREATE INDEX IF NOT EXISTS delete_by_time_index ON $tableName($columnTrashDeleteBy);
|
||||
CREATE INDEX IF NOT EXISTS updated_at_time_index ON $tableName($columnTrashUpdatedAt);
|
||||
''',
|
||||
);
|
||||
}
|
||||
|
||||
TrashDB._privateConstructor();
|
||||
|
||||
static final TrashDB instance = TrashDB._privateConstructor();
|
||||
|
||||
// only have a single app-wide reference to the database
|
||||
static Future<Database>? _dbFuture;
|
||||
|
||||
Future<Database> get database async {
|
||||
// lazily instantiate the db the first time it is accessed
|
||||
_dbFuture ??= _initDatabase();
|
||||
return _dbFuture!;
|
||||
}
|
||||
|
||||
// this opens the database (and creates it if it doesn't exist)
|
||||
Future<Database> _initDatabase() async {
|
||||
final Directory documentsDirectory =
|
||||
await getApplicationDocumentsDirectory();
|
||||
final String path = join(documentsDirectory.path, _databaseName);
|
||||
_logger.info("DB path " + path);
|
||||
return await openDatabase(
|
||||
path,
|
||||
version: _databaseVersion,
|
||||
onCreate: _onCreate,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> clearTable() async {
|
||||
final db = await instance.database;
|
||||
await db.delete(tableName);
|
||||
}
|
||||
|
||||
Future<int> count() async {
|
||||
final db = await instance.database;
|
||||
final count = Sqflite.firstIntValue(
|
||||
await db.rawQuery('SELECT COUNT(*) FROM $tableName'),
|
||||
);
|
||||
return count ?? 0;
|
||||
}
|
||||
|
||||
Future<void> insertMultiple(List<TrashFile> trashFiles) async {
|
||||
final startTime = DateTime.now();
|
||||
final db = await instance.database;
|
||||
var batch = db.batch();
|
||||
int batchCounter = 0;
|
||||
for (TrashFile trash in trashFiles) {
|
||||
if (batchCounter == 400) {
|
||||
await batch.commit(noResult: true);
|
||||
batch = db.batch();
|
||||
batchCounter = 0;
|
||||
}
|
||||
batch.insert(
|
||||
tableName,
|
||||
_getRowForTrash(trash),
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
batchCounter++;
|
||||
}
|
||||
await batch.commit(noResult: true);
|
||||
final endTime = DateTime.now();
|
||||
final duration = Duration(
|
||||
microseconds:
|
||||
endTime.microsecondsSinceEpoch - startTime.microsecondsSinceEpoch,
|
||||
);
|
||||
_logger.info(
|
||||
"Batch insert of " +
|
||||
trashFiles.length.toString() +
|
||||
" took " +
|
||||
duration.inMilliseconds.toString() +
|
||||
"ms.",
|
||||
);
|
||||
}
|
||||
|
||||
Future<int> delete(List<int> uploadedFileIDs) async {
|
||||
final db = await instance.database;
|
||||
return db.delete(
|
||||
tableName,
|
||||
where: '$columnUploadedFileID IN (${uploadedFileIDs.join(', ')})',
|
||||
);
|
||||
}
|
||||
|
||||
Future<int> update(TrashFile file) async {
|
||||
final db = await instance.database;
|
||||
return await db.update(
|
||||
tableName,
|
||||
_getRowForTrash(file),
|
||||
where: '$columnUploadedFileID = ?',
|
||||
whereArgs: [file.uploadedFileID],
|
||||
);
|
||||
}
|
||||
|
||||
Future<FileLoadResult> getTrashedFiles(
|
||||
int startTime,
|
||||
int endTime, {
|
||||
int? limit,
|
||||
bool? asc,
|
||||
}) async {
|
||||
final db = await instance.database;
|
||||
final order = (asc ?? false ? 'ASC' : 'DESC');
|
||||
final results = await db.query(
|
||||
tableName,
|
||||
where: '$columnCreationTime >= ? AND $columnCreationTime <= ?',
|
||||
whereArgs: [startTime, endTime],
|
||||
orderBy: '$columnCreationTime ' + order,
|
||||
limit: limit,
|
||||
);
|
||||
final files = _convertToFiles(results);
|
||||
return FileLoadResult(files, files.length == limit);
|
||||
}
|
||||
|
||||
List<TrashFile> _convertToFiles(List<Map<String, dynamic>> results) {
|
||||
final List<TrashFile> trashedFiles = [];
|
||||
for (final result in results) {
|
||||
trashedFiles.add(_getTrashFromRow(result));
|
||||
}
|
||||
return trashedFiles;
|
||||
}
|
||||
|
||||
TrashFile _getTrashFromRow(Map<String, dynamic> row) {
|
||||
final trashFile = TrashFile();
|
||||
trashFile.updateAt = row[columnTrashUpdatedAt];
|
||||
trashFile.deleteBy = row[columnTrashDeleteBy];
|
||||
trashFile.uploadedFileID = row[columnUploadedFileID];
|
||||
// dirty hack to ensure that the file_downloads & cache mechanism works
|
||||
trashFile.generatedID = -1 * trashFile.uploadedFileID!;
|
||||
trashFile.ownerID = row[columnOwnerID];
|
||||
trashFile.collectionID =
|
||||
row[columnCollectionID] == -1 ? null : row[columnCollectionID];
|
||||
trashFile.encryptedKey = row[columnEncryptedKey];
|
||||
trashFile.keyDecryptionNonce = row[columnKeyDecryptionNonce];
|
||||
trashFile.fileDecryptionHeader = row[columnFileDecryptionHeader];
|
||||
trashFile.thumbnailDecryptionHeader = row[columnThumbnailDecryptionHeader];
|
||||
trashFile.updationTime = row[columnUpdationTime] ?? 0;
|
||||
trashFile.creationTime = row[columnCreationTime];
|
||||
final fileMetadata = row[columnFileMetadata] ?? '{}';
|
||||
trashFile.applyMetadata(jsonDecode(fileMetadata));
|
||||
trashFile.localID = row[columnLocalID];
|
||||
|
||||
trashFile.mMdVersion = row[columnMMdVersion] ?? 0;
|
||||
trashFile.mMdEncodedJson = row[columnMMdEncodedJson] ?? '{}';
|
||||
|
||||
trashFile.pubMmdVersion = row[columnPubMMdVersion] ?? 0;
|
||||
trashFile.pubMmdEncodedJson = row[columnPubMMdEncodedJson] ?? '{}';
|
||||
|
||||
if (trashFile.pubMagicMetadata != null &&
|
||||
trashFile.pubMagicMetadata!.editedTime != null) {
|
||||
// override existing creationTime to avoid re-writing all queries related
|
||||
// to loading the gallery
|
||||
row[columnCreationTime] = trashFile.pubMagicMetadata!.editedTime!;
|
||||
}
|
||||
|
||||
return trashFile;
|
||||
}
|
||||
|
||||
Map<String, dynamic> _getRowForTrash(TrashFile trash) {
|
||||
final row = <String, dynamic>{};
|
||||
row[columnTrashUpdatedAt] = trash.updateAt;
|
||||
row[columnTrashDeleteBy] = trash.deleteBy;
|
||||
row[columnUploadedFileID] = trash.uploadedFileID;
|
||||
row[columnCollectionID] = trash.collectionID;
|
||||
row[columnOwnerID] = trash.ownerID;
|
||||
row[columnEncryptedKey] = trash.encryptedKey;
|
||||
row[columnKeyDecryptionNonce] = trash.keyDecryptionNonce;
|
||||
row[columnFileDecryptionHeader] = trash.fileDecryptionHeader;
|
||||
row[columnThumbnailDecryptionHeader] = trash.thumbnailDecryptionHeader;
|
||||
row[columnUpdationTime] = trash.updationTime;
|
||||
|
||||
row[columnLocalID] = trash.localID;
|
||||
row[columnCreationTime] = trash.creationTime;
|
||||
row[columnFileMetadata] = jsonEncode(trash.metadata);
|
||||
|
||||
row[columnMMdVersion] = trash.mMdVersion;
|
||||
row[columnMMdEncodedJson] = trash.mMdEncodedJson ?? '{}';
|
||||
|
||||
row[columnPubMMdVersion] = trash.pubMmdVersion;
|
||||
row[columnPubMMdEncodedJson] = trash.pubMmdEncodedJson ?? '{}';
|
||||
return row;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import "package:photos/events/event.dart";
|
||||
|
||||
class FileCaptionUpdatedEvent extends Event {
|
||||
final int fileGeneratedID;
|
||||
final String fileTag;
|
||||
|
||||
FileCaptionUpdatedEvent(this.fileGeneratedID);
|
||||
FileCaptionUpdatedEvent(this.fileTag);
|
||||
}
|
||||
|
||||
10
mobile/apps/photos/lib/events/v1/LocalAssetChangedEvent.dart
Normal file
10
mobile/apps/photos/lib/events/v1/LocalAssetChangedEvent.dart
Normal file
@@ -0,0 +1,10 @@
|
||||
import "package:photos/events/event.dart";
|
||||
|
||||
class LocalAssetChangedEvent extends Event {
|
||||
final String source;
|
||||
|
||||
LocalAssetChangedEvent(this.source);
|
||||
|
||||
@override
|
||||
String get reason => '$runtimeType{"via": $source}';
|
||||
}
|
||||
@@ -39,20 +39,26 @@ class EnteWatch extends Stopwatch {
|
||||
class TimeLogger {
|
||||
final String context;
|
||||
final int logThreshold;
|
||||
DateTime _start;
|
||||
TimeLogger({this.context = "TLog", this.logThreshold = 5})
|
||||
final DateTime _start;
|
||||
DateTime _toStringStart = DateTime.now();
|
||||
TimeLogger({this.context = "TLog:", this.logThreshold = 5})
|
||||
: _start = DateTime.now();
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
final int diff = DateTime.now().difference(_start).inMilliseconds;
|
||||
final int diff = DateTime.now().difference(_toStringStart).inMilliseconds;
|
||||
late String res;
|
||||
if (diff > logThreshold) {
|
||||
res = "[$context: $diff ms]";
|
||||
res = "[$context$diff ms]";
|
||||
} else {
|
||||
res = "[]";
|
||||
}
|
||||
_start = DateTime.now();
|
||||
_toStringStart = DateTime.now();
|
||||
return res;
|
||||
}
|
||||
|
||||
String get elapsed {
|
||||
final int diff = DateTime.now().difference(_start).inMilliseconds;
|
||||
return "[$context$diff ms]";
|
||||
}
|
||||
}
|
||||
|
||||
90
mobile/apps/photos/lib/image/in_memory_image_cache.dart
Normal file
90
mobile/apps/photos/lib/image/in_memory_image_cache.dart
Normal file
@@ -0,0 +1,90 @@
|
||||
import "package:flutter/foundation.dart";
|
||||
import "package:photos/core/cache/lru_map.dart";
|
||||
import "package:photos/models/file/file.dart";
|
||||
|
||||
// Singleton instance for global access
|
||||
final enteImageCache = InMemoryImageCache._instance;
|
||||
|
||||
class InMemoryImageCache {
|
||||
static final InMemoryImageCache _instance = InMemoryImageCache._();
|
||||
|
||||
// Private constructor for singleton
|
||||
InMemoryImageCache._();
|
||||
|
||||
// Supported dimensions with associated cache sizes
|
||||
static const Map<int, int> _cacheSizes = {
|
||||
32: 5000, // Small: 32*32 = 1024 bytes * 5000 = 6.25MB
|
||||
256: 2000, // Medium: 256*256 = 65536 bytes * 2000 = 128MB
|
||||
512: 100, // Large: 512*512 = 262144 bytes * 100 = 25MB
|
||||
};
|
||||
|
||||
// Cache instances for each dimension
|
||||
final Map<int, LRUMap<String, Uint8List?>> _caches = {
|
||||
32: LRUMap<String, Uint8List?>(5000),
|
||||
256: LRUMap<String, Uint8List?>(2000),
|
||||
512: LRUMap<String, Uint8List?>(100),
|
||||
};
|
||||
|
||||
/// Gets a thumbnail for a file at the specified dimension
|
||||
Uint8List? getThumb(EnteFile file, int dimension) {
|
||||
return _getFromCache(file.cacheKey(), dimension);
|
||||
}
|
||||
|
||||
/// Gets a thumbnail by ID at the specified dimension
|
||||
Uint8List? getThumbByID(String id, int dimension) {
|
||||
return _getFromCache(id, dimension);
|
||||
}
|
||||
|
||||
/// Stores a thumbnail for a file at the specified dimension
|
||||
void putThumb(EnteFile file, Uint8List? imageData, int dimension) {
|
||||
_putInCache(file.cacheKey(), imageData, dimension);
|
||||
}
|
||||
|
||||
/// Stores a thumbnail by ID at the specified dimension
|
||||
void putThumbByID(String id, Uint8List? imageData, int dimension) {
|
||||
_putInCache(id, imageData, dimension);
|
||||
}
|
||||
|
||||
/// Checks if a thumbnail exists for a file at the specified dimension
|
||||
bool containsThumb(EnteFile file, int dimension) {
|
||||
return _isCached(file.cacheKey(), dimension);
|
||||
}
|
||||
|
||||
void clearCache(EnteFile file) {
|
||||
_caches.forEach((_, cache) {
|
||||
cache.remove(file.cacheKey());
|
||||
});
|
||||
}
|
||||
|
||||
// Private helper methods
|
||||
|
||||
Uint8List? _getFromCache(String key, int dimension) {
|
||||
if (_isValidDimension(dimension)) {
|
||||
return _caches[dimension]?.get(key);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
void _putInCache(String key, Uint8List? imageData, int dimension) {
|
||||
if (_isValidDimension(dimension)) {
|
||||
_caches[dimension]?.put(key, imageData);
|
||||
} else {
|
||||
debugPrint("Unsupported dimension: $dimension");
|
||||
}
|
||||
}
|
||||
|
||||
bool _isCached(String key, int dimension) {
|
||||
if (_isValidDimension(dimension)) {
|
||||
return _caches[dimension]?.containsKey(key) ?? false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool _isValidDimension(int dimension) {
|
||||
if (_caches.containsKey(dimension)) {
|
||||
return true;
|
||||
}
|
||||
debugPrint("Invalid dimension: $dimension");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
193
mobile/apps/photos/lib/image/provider/local_thumbnail_img.dart
Normal file
193
mobile/apps/photos/lib/image/provider/local_thumbnail_img.dart
Normal file
@@ -0,0 +1,193 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import "package:equatable/equatable.dart";
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/painting.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
import "package:photos/image/in_memory_image_cache.dart";
|
||||
import "package:photos/utils/standalone/task_queue.dart";
|
||||
|
||||
final thumbnailQueue = TaskQueue<String>(
|
||||
maxConcurrentTasks: 15,
|
||||
taskTimeout: const Duration(minutes: 1),
|
||||
maxQueueSize: 200, // Limit the queue to 50 pending tasks
|
||||
);
|
||||
|
||||
final mediumThumbnailQueue = TaskQueue<String>(
|
||||
maxConcurrentTasks: 5,
|
||||
taskTimeout: const Duration(minutes: 1),
|
||||
maxQueueSize: 200, // Limit the queue to 50 pending tasks
|
||||
);
|
||||
|
||||
class LocalThumbnailProvider extends ImageProvider<LocalThumbnailProviderKey> {
|
||||
final LocalThumbnailProviderKey key;
|
||||
final int maxRetries;
|
||||
final Duration retryDelay;
|
||||
|
||||
LocalThumbnailProvider(
|
||||
this.key, {
|
||||
this.maxRetries = 300,
|
||||
this.retryDelay = const Duration(milliseconds: 5),
|
||||
});
|
||||
|
||||
@override
|
||||
Future<LocalThumbnailProviderKey> obtainKey(
|
||||
ImageConfiguration configuration,
|
||||
) async {
|
||||
return SynchronousFuture<LocalThumbnailProviderKey>(key);
|
||||
}
|
||||
|
||||
static cancelRequest(LocalThumbnailProviderKey key) {
|
||||
thumbnailQueue.removeTask('${key.asset.id}-small');
|
||||
mediumThumbnailQueue.removeTask('${key.asset.id}-medium');
|
||||
}
|
||||
|
||||
@override
|
||||
ImageStreamCompleter loadImage(
|
||||
LocalThumbnailProviderKey key,
|
||||
ImageDecoderCallback decode,
|
||||
) {
|
||||
final chunkEvents = StreamController<ImageChunkEvent>();
|
||||
return MultiImageStreamCompleter(
|
||||
codec: _codec(key, decode, chunkEvents),
|
||||
scale: 1.0,
|
||||
chunkEvents: chunkEvents.stream,
|
||||
informationCollector: () sync* {
|
||||
yield ErrorDescription('id: ${key.asset.id} name: ${key.asset.title}');
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Stream<ui.Codec> _codec(
|
||||
LocalThumbnailProviderKey key,
|
||||
ImageDecoderCallback decode,
|
||||
StreamController<ImageChunkEvent> chunkEvents,
|
||||
) async* {
|
||||
// First try to get from cache
|
||||
Uint8List? normalThumbBytes =
|
||||
enteImageCache.getThumbByID(key.asset.id, key.height);
|
||||
if (normalThumbBytes != null) {
|
||||
final buffer = await ui.ImmutableBuffer.fromUint8List(normalThumbBytes);
|
||||
final codec = await decode(buffer);
|
||||
yield codec;
|
||||
chunkEvents.close().ignore();
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to load small thumbnail with retry logic
|
||||
final Uint8List? thumbBytes = await _loadWithRetry(
|
||||
key: key,
|
||||
size: ThumbnailSize(key.smallThumbWidth, key.smallThumbHeight),
|
||||
quality: 75,
|
||||
cacheKey: '${key.asset.id}-small',
|
||||
queue: thumbnailQueue,
|
||||
cacheWidth: key.smallThumbWidth,
|
||||
);
|
||||
|
||||
if (thumbBytes != null) {
|
||||
final buffer = await ui.ImmutableBuffer.fromUint8List(thumbBytes);
|
||||
final codec = await decode(buffer);
|
||||
yield codec;
|
||||
} else {
|
||||
debugPrint("$runtimeType smallThumb ${key.asset.title} failed");
|
||||
}
|
||||
|
||||
// Try to load normal thumbnail with retry logic if not already in cache
|
||||
if (normalThumbBytes == null) {
|
||||
normalThumbBytes = await _loadWithRetry(
|
||||
key: key,
|
||||
size: ThumbnailSize(key.width, key.height),
|
||||
quality: 50,
|
||||
cacheKey: '${key.asset.id}-medium',
|
||||
queue: mediumThumbnailQueue,
|
||||
cacheWidth: key.height,
|
||||
);
|
||||
|
||||
if (normalThumbBytes == null) {
|
||||
throw StateError("$runtimeType biThumb ${key.asset.title} failed");
|
||||
}
|
||||
|
||||
final buffer = await ui.ImmutableBuffer.fromUint8List(normalThumbBytes);
|
||||
final codec = await decode(buffer);
|
||||
yield codec;
|
||||
}
|
||||
|
||||
chunkEvents.close().ignore();
|
||||
}
|
||||
|
||||
Future<Uint8List?> _loadWithRetry({
|
||||
required LocalThumbnailProviderKey key,
|
||||
required ThumbnailSize size,
|
||||
required int quality,
|
||||
required String cacheKey,
|
||||
required TaskQueue<String> queue,
|
||||
required int cacheWidth,
|
||||
}) async {
|
||||
int attempt = 0;
|
||||
Uint8List? result;
|
||||
|
||||
while (attempt <= maxRetries) {
|
||||
try {
|
||||
// Check cache first on retry attempts
|
||||
if (attempt > 0) {
|
||||
result = enteImageCache.getThumbByID(key.asset.id, cacheWidth);
|
||||
if (result != null) return result;
|
||||
}
|
||||
|
||||
final Completer<Uint8List?> future = Completer();
|
||||
await queue.addTask(cacheKey, () async {
|
||||
final bytes =
|
||||
await key.asset.thumbnailDataWithSize(size, quality: quality);
|
||||
enteImageCache.putThumbByID(key.asset.id, bytes, cacheWidth);
|
||||
future.complete(bytes);
|
||||
});
|
||||
result = await future.future;
|
||||
return result;
|
||||
} catch (e) {
|
||||
// Only retry on specific exceptions
|
||||
if (e is! TaskQueueOverflowException &&
|
||||
e is! TaskQueueTimeoutException &&
|
||||
e is! TaskQueueCancelledException) {
|
||||
rethrow;
|
||||
}
|
||||
|
||||
attempt++;
|
||||
if (attempt <= maxRetries) {
|
||||
await Future.delayed(retryDelay * attempt); // Exponential backoff
|
||||
} else {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@immutable
|
||||
class LocalThumbnailProviderKey extends Equatable {
|
||||
final AssetEntity asset;
|
||||
final int height;
|
||||
final int width;
|
||||
final int smallThumbHeight;
|
||||
final int smallThumbWidth;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
asset.id,
|
||||
asset.modifiedDateSecond ?? 0,
|
||||
height,
|
||||
width,
|
||||
smallThumbHeight,
|
||||
smallThumbWidth,
|
||||
];
|
||||
|
||||
const LocalThumbnailProviderKey({
|
||||
required this.asset,
|
||||
this.height = 256,
|
||||
this.width = 256,
|
||||
this.smallThumbWidth = 32,
|
||||
this.smallThumbHeight = 32,
|
||||
});
|
||||
}
|
||||
@@ -372,7 +372,7 @@
|
||||
"deleteFromBoth": "الحذف من كليهما",
|
||||
"newAlbum": "ألبوم جديد",
|
||||
"albums": "الألبومات",
|
||||
"memoryCount": "{count, plural, =0 {لا توجد ذكريات} one {ذكرى واحدة} two {ذكريتان} few {{formattedCount} ذكريات} many {{formattedCount} ذكرى} other {{formattedCount} ذكرى}}",
|
||||
"memoryCount": "{count, plural, =0 {لا توجد ذكريات} one {ذكرى واحدة} two {ذكريتان} other {{formattedCount} ذكرى}}",
|
||||
"@memoryCount": {
|
||||
"description": "The text to display the number of memories",
|
||||
"type": "text",
|
||||
@@ -460,7 +460,7 @@
|
||||
"skip": "تخط",
|
||||
"updatingFolderSelection": "جارٍ تحديث تحديد المجلد...",
|
||||
"itemCount": "{count, plural, one {{count} عُنْصُر} other {{count} عَنَاصِر}}",
|
||||
"deleteItemCount": "{count, plural, =1 {حذف عنصر واحد} two {حذف عنصرين} few {حذف {count} عناصر} many {حذف {count} عنصرًا} other {حذف {count} عنصرًا}}",
|
||||
"deleteItemCount": "{count, plural, =1 {حذف عنصر واحد} two {حذف عنصرين} other {حذف {count} عنصرًا}}",
|
||||
"duplicateItemsGroup": "{count} ملفات، {formattedSize} لكل منها",
|
||||
"@duplicateItemsGroup": {
|
||||
"description": "Display the number of duplicate files and their size",
|
||||
@@ -477,7 +477,7 @@
|
||||
}
|
||||
},
|
||||
"showMemories": "عرض الذكريات",
|
||||
"yearsAgo": "{count, plural, one {قبل سنة} two {قبل سنتين} few {قبل {count} سنوات} many {قبل {count} سنة} other {قبل {count} سنة}}",
|
||||
"yearsAgo": "{count, plural, one {قبل سنة} two {قبل سنتين} other {قبل {count} سنة}}",
|
||||
"backupSettings": "إعدادات النسخ الاحتياطي",
|
||||
"backupStatus": "حالة النسخ الاحتياطي",
|
||||
"backupStatusDescription": "ستظهر العناصر التي تم نسخها احتياطيًا هنا",
|
||||
@@ -543,7 +543,7 @@
|
||||
},
|
||||
"remindToEmptyEnteTrash": "تذكر أيضًا إفراغ \"سلة المهملات\" لاستعادة المساحة المحررة.",
|
||||
"sparkleSuccess": "✨ نجاح",
|
||||
"duplicateFileCountWithStorageSaved": "لقد قمت بتنظيف {count, plural, one {ملف مكرر واحد} two {ملفين مكررين} few {{count} ملفات مكررة} many {{count} ملفًا مكررًا} other {{count} ملفًا مكررًا}}، مما وفر {storageSaved}!",
|
||||
"duplicateFileCountWithStorageSaved": "لقد قمت بتنظيف {count, plural, one {ملف مكرر واحد} two {ملفين مكررين} other {{count} ملفًا مكررًا}}، مما وفر {storageSaved}!",
|
||||
"@duplicateFileCountWithStorageSaved": {
|
||||
"description": "The text to display when the user has successfully cleaned up duplicate files",
|
||||
"type": "text",
|
||||
@@ -794,11 +794,11 @@
|
||||
"share": "مشاركة",
|
||||
"unhideToAlbum": "إظهار في الألبوم",
|
||||
"restoreToAlbum": "استعادة إلى الألبوم",
|
||||
"moveItem": "{count, plural, =1 {نقل عنصر} two {نقل عنصرين} few {نقل {count} عناصر} many {نقل {count} عنصرًا} other {نقل {count} عنصرًا}}",
|
||||
"moveItem": "{count, plural, =1 {نقل عنصر} two {نقل عنصرين} other {نقل {count} عنصرًا}}",
|
||||
"@moveItem": {
|
||||
"description": "Page title while moving one or more items to an album"
|
||||
},
|
||||
"addItem": "{count, plural, =1 {إضافة عنصر} two {إضافة عنصرين} few {إضافة {count} عناصر} many {إضافة {count} عنصرًا} other {إضافة {count} عنصرًا}}",
|
||||
"addItem": "{count, plural, =1 {إضافة عنصر} two {إضافة عنصرين} other {إضافة {count} عنصرًا}}",
|
||||
"@addItem": {
|
||||
"description": "Page title while adding one or more items to album"
|
||||
},
|
||||
@@ -826,7 +826,7 @@
|
||||
"referFriendsAnd2xYourPlan": "أحِل الأصدقاء وضاعف خطتك مرتين",
|
||||
"shareAlbumHint": "افتح ألبومًا وانقر على زر المشاركة في الزاوية اليمنى العليا للمشاركة.",
|
||||
"itemsShowTheNumberOfDaysRemainingBeforePermanentDeletion": "تعرض العناصر عدد الأيام المتبقية قبل الحذف الدائم.",
|
||||
"trashDaysLeft": "{count, plural, =0 {قريبًا} =1 {يوم واحد} two {يومان} few {{count} أيام} many {{count} يومًا} other {{count} يومًا}}",
|
||||
"trashDaysLeft": "{count, plural, =0 {قريبًا} =1 {يوم واحد} two {يومان} other {{count} يومًا}}",
|
||||
"@trashDaysLeft": {
|
||||
"description": "Text to indicate number of days remaining before permanent deletion",
|
||||
"placeholders": {
|
||||
@@ -899,8 +899,8 @@
|
||||
"authToViewYourMemories": "يرجى المصادقة لعرض ذكرياتك.",
|
||||
"unlock": "فتح",
|
||||
"freeUpSpace": "تحرير المساحة",
|
||||
"freeUpSpaceSaving": "{count, plural, =1 {يمكن حذفه من الجهاز لتحرير {formattedSize}} two {يمكن حذفهما من الجهاز لتحرير {formattedSize}} few {يمكن حذفها من الجهاز لتحرير {formattedSize}} many {يمكن حذفها من الجهاز لتحرير {formattedSize}} other {يمكن حذفها من الجهاز لتحرير {formattedSize}}}",
|
||||
"filesBackedUpInAlbum": "{count, plural, one {ملف واحد} two {ملفان} few {{formattedNumber} ملفات} many {{formattedNumber} ملفًا} other {{formattedNumber} ملفًا}} في هذا الألبوم تم نسخه احتياطيًا بأمان",
|
||||
"freeUpSpaceSaving": "{count, plural, =1 {يمكن حذفه من الجهاز لتحرير {formattedSize}} two {يمكن حذفهما من الجهاز لتحرير {formattedSize}} other {يمكن حذفها من الجهاز لتحرير {formattedSize}}}",
|
||||
"filesBackedUpInAlbum": "{count, plural, one {ملف واحد} two {ملفان} other {{formattedNumber} ملفًا}} في هذا الألبوم تم نسخه احتياطيًا بأمان",
|
||||
"@filesBackedUpInAlbum": {
|
||||
"description": "Text to tell user how many files have been backed up in the album",
|
||||
"placeholders": {
|
||||
@@ -915,7 +915,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"filesBackedUpFromDevice": "{count, plural, one {ملف واحد} two {ملفان} few {{formattedNumber} ملفات} many {{formattedNumber} ملفًا} other {{formattedNumber} ملفًا}} على هذا الجهاز تم نسخه احتياطيًا بأمان",
|
||||
"filesBackedUpFromDevice": "{count, plural, one {ملف واحد} two {ملفان} other {{formattedNumber} ملفًا}} على هذا الجهاز تم نسخه احتياطيًا بأمان",
|
||||
"@filesBackedUpFromDevice": {
|
||||
"description": "Text to tell user how many files have been backed up from this device",
|
||||
"placeholders": {
|
||||
@@ -1217,7 +1217,7 @@
|
||||
"searchHint4": "الموقع",
|
||||
"searchHint5": "قريبًا: الوجوه والبحث السحري ✨",
|
||||
"addYourPhotosNow": "أضف صورك الآن",
|
||||
"searchResultCount": "{count, plural, one{{count} النتائج التي تم العثور عليها} other{{count} النتائج التي تم العثور عليها}}",
|
||||
"searchResultCount": "{count, plural, other{{count} النتائج التي تم العثور عليها}}",
|
||||
"@searchResultCount": {
|
||||
"description": "Text to tell user how many results were found for their search query",
|
||||
"placeholders": {
|
||||
@@ -1269,8 +1269,8 @@
|
||||
"description": "Subtitle to indicate that the user can find people quickly by name"
|
||||
},
|
||||
"findPeopleByName": "البحث عن الأشخاص بسرعة بالاسم",
|
||||
"addViewers": "{count, plural, =0 {إضافة مشاهد} =1 {إضافة مشاهد} two {إضافة مشاهدين} few {إضافة {count} مشاهدين} many {إضافة {count} مشاهدًا} other {إضافة {count} مشاهدًا}}",
|
||||
"addCollaborators": "{count, plural, =0 {إضافة متعاون} =1 {إضافة متعاون} two {إضافة متعاونين} few {إضافة {count} متعاونين} many {إضافة {count} متعاونًا} other {إضافة {count} متعاونًا}}",
|
||||
"addViewers": "{count, plural, =0 {إضافة مشاهد} =1 {إضافة مشاهد} two {إضافة مشاهدين} other {إضافة {count} مشاهدًا}}",
|
||||
"addCollaborators": "{count, plural, =0 {إضافة متعاون} =1 {إضافة متعاون} two {إضافة متعاونين} other {إضافة {count} متعاونًا}}",
|
||||
"longPressAnEmailToVerifyEndToEndEncryption": "اضغط مطولاً على بريد إلكتروني للتحقق من التشفير من طرف إلى طرف.",
|
||||
"developerSettingsWarning": "هل أنت متأكد من رغبتك في تعديل إعدادات المطور؟",
|
||||
"developerSettings": "إعدادات المطور",
|
||||
@@ -1403,7 +1403,7 @@
|
||||
"enableMachineLearningBanner": "قم بتمكين تعلم الآلة للبحث السحري والتعرف على الوجوه.",
|
||||
"searchDiscoverEmptySection": "سيتم عرض الصور هنا بمجرد اكتمال المعالجة والمزامنة.",
|
||||
"searchPersonsEmptySection": "سيتم عرض الأشخاص هنا بمجرد اكتمال المعالجة والمزامنة.",
|
||||
"viewersSuccessfullyAdded": "{count, plural, =0 {تمت إضافة 0 مشاهدين} =1 {تمت إضافة مشاهد واحد} two {تمت إضافة مشاهدين} few {تمت إضافة {count} مشاهدين} many {تمت إضافة {count} مشاهدًا} other {تمت إضافة {count} مشاهدًا}}",
|
||||
"viewersSuccessfullyAdded": "{count, plural, =0 {تمت إضافة 0 مشاهدين} =1 {تمت إضافة مشاهد واحد} two {تمت إضافة مشاهدين} other {تمت إضافة {count} مشاهدًا}}",
|
||||
"@viewersSuccessfullyAdded": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
@@ -1413,7 +1413,7 @@
|
||||
},
|
||||
"description": "Number of viewers that were successfully added to an album."
|
||||
},
|
||||
"collaboratorsSuccessfullyAdded": "{count, plural, =0 {تمت إضافة 0 متعاونين} =1 {تمت إضافة متعاون واحد} two {تمت إضافة متعاونين} few {تمت إضافة {count} متعاونين} many {تمت إضافة {count} متعاونًا} other {تمت إضافة {count} متعاونًا}}",
|
||||
"collaboratorsSuccessfullyAdded": "{count, plural, =0 {تمت إضافة 0 متعاونين} =1 {تمت إضافة متعاون واحد} two {تمت إضافة متعاونين} other {تمت إضافة {count} متعاونًا}}",
|
||||
"@collaboratorsSuccessfullyAdded": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
@@ -1488,7 +1488,7 @@
|
||||
},
|
||||
"currentlyRunning": "قيد التشغيل حاليًا",
|
||||
"ignored": "تم التجاهل",
|
||||
"photosCount": "{count, plural, =0 {لا توجد صور} =1 {صورة واحدة} two {صورتان} few {{count} صور} many {{count} صورة} other {{count} صورة}}",
|
||||
"photosCount": "{count, plural, =0 {لا توجد صور} =1 {صورة واحدة} two {صورتان} other {{count} صورة}}",
|
||||
"@photosCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
@@ -1686,7 +1686,7 @@
|
||||
"moveSelectedPhotosToOneDate": "نقل الصور المحددة إلى تاريخ واحد",
|
||||
"shiftDatesAndTime": "تغيير التواريخ والوقت",
|
||||
"photosKeepRelativeTimeDifference": "تحتفظ الصور بالفرق الزمني النسبي",
|
||||
"photocountPhotos": "{count, plural, =0 {لا توجد صور} =1 {صورة واحدة} two {صورتان} few {{count} صور} many {{count} صورة} other {{count} صورة}}",
|
||||
"photocountPhotos": "{count, plural, =0 {لا توجد صور} =1 {صورة واحدة} two {صورتان} other {{count} صورة}}",
|
||||
"@photocountPhotos": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
@@ -1700,7 +1700,7 @@
|
||||
"selectedItemsWillBeRemovedFromThisPerson": "سيتم إزالة العناصر المحددة من هذا الشخص، ولكن لن يتم حذفها من مكتبتك.",
|
||||
"throughTheYears": "{dateFormat} عبر السنين",
|
||||
"thisWeekThroughTheYears": "هذا الأسبوع عبر السنين",
|
||||
"thisWeekXYearsAgo": "{count, plural, =1 {هذا الأسبوع، قبل سنة} two {هذا الأسبوع، قبل سنتين} few {هذا الأسبوع، قبل {count} سنوات} many {هذا الأسبوع، قبل {count} سنة} other {هذا الأسبوع، قبل {count} سنة}}",
|
||||
"thisWeekXYearsAgo": "{count, plural, =1 {هذا الأسبوع، قبل سنة} two {هذا الأسبوع، قبل سنتين} other {هذا الأسبوع، قبل {count} سنة}}",
|
||||
"youAndThem": "أنت و {name}",
|
||||
"admiringThem": "الإعجاب بـ {name}",
|
||||
"embracingThem": "معانقة {name}",
|
||||
@@ -1776,6 +1776,11 @@
|
||||
"same": "نفس",
|
||||
"different": "مختلف",
|
||||
"sameperson": "نفس الشخص؟",
|
||||
"cLTitle1": "محرر الصور المتقدم",
|
||||
"cLDesc1": "نحن بصدد إطلاق محرر صور جديد ومتقدم يضيف المزيد من إطارات الاقتصاص، والإعدادات المسبقة للفلاتر من أجل تعديلات سريعة، وخيارات الضبط الدقيق التي تشمل التشبع، والتباين، والسطوع، ودرجة الحرارة، وغير ذلك الكثير. يتضمن المحرر الجديد أيضا القدرة على الرسم على صورك وإضافة الرموز التعبيرية كملصقات.",
|
||||
"cLTitle2": "ألبومات ذكية",
|
||||
"cLTitle3": "معرض محسن",
|
||||
"cLTitle4": "تمرير أسرع",
|
||||
"thisWeek": "هذا الأسبوع",
|
||||
"lastWeek": "الأسبوع الماضي",
|
||||
"thisMonth": "هذا الشهر",
|
||||
@@ -1816,4 +1821,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -372,7 +372,7 @@
|
||||
"deleteFromBoth": "Odstranit z obou",
|
||||
"newAlbum": "Nové album",
|
||||
"albums": "Alba",
|
||||
"memoryCount": "{count, plural, =0{žádné vzpomínky} one{{formattedCount} vzpomínka} few{{formattedCount} vzpomínky} other{{formattedCount} vzpomínek}}",
|
||||
"memoryCount": "{count, plural, =0{žádné vzpomínky} one{{formattedCount} vzpomínka} other{{formattedCount} vzpomínek}}",
|
||||
"@memoryCount": {
|
||||
"description": "The text to display the number of memories",
|
||||
"type": "text",
|
||||
@@ -459,8 +459,8 @@
|
||||
"selectAll": "Vybrat vše",
|
||||
"skip": "Přeskočit",
|
||||
"updatingFolderSelection": "Aktualizuji výběr složek...",
|
||||
"itemCount": "{count, plural, one{{count} položka} few{{count} položky} other{{count} položek}}",
|
||||
"deleteItemCount": "{count, plural, =1 {Smazat {count} položku} few{Smazat {count} položky} other {Smazat {count} položek}}",
|
||||
"itemCount": "{count, plural, one{{count} položka} other{{count} položek}}",
|
||||
"deleteItemCount": "{count, plural, =1 {Smazat {count} položku} other {Smazat {count} položek}}",
|
||||
"duplicateItemsGroup": "{count} souborů, {formattedSize} každý",
|
||||
"@duplicateItemsGroup": {
|
||||
"description": "Display the number of duplicate files and their size",
|
||||
@@ -543,7 +543,7 @@
|
||||
},
|
||||
"remindToEmptyEnteTrash": "Vyprázdněte také \"Koš\", abyste získali uvolněné místo",
|
||||
"sparkleSuccess": "✨ Úspěch",
|
||||
"duplicateFileCountWithStorageSaved": "Vyčistili jste {count, plural, one{{count} duplicitní soubor} few{{count} duplicitní soubory} other{{count} duplicitních souborů}}, a ušetřili jste {storageSaved}!",
|
||||
"duplicateFileCountWithStorageSaved": "Vyčistili jste {count, plural, one{{count} duplicitní soubor} other{{count} duplicitních souborů}}, a ušetřili jste {storageSaved}!",
|
||||
"@duplicateFileCountWithStorageSaved": {
|
||||
"description": "The text to display when the user has successfully cleaned up duplicate files",
|
||||
"type": "text",
|
||||
@@ -826,7 +826,7 @@
|
||||
"referFriendsAnd2xYourPlan": "Doporučte přátele a zdvojnásobte svůj tarif",
|
||||
"shareAlbumHint": "Otevřete album a klepněte na tlačítko sdílení v pravém horním rohu, abyste jej sdíleli.",
|
||||
"itemsShowTheNumberOfDaysRemainingBeforePermanentDeletion": "Položky zobrazují počet dní zbývajících do trvalého smazání",
|
||||
"trashDaysLeft": "{count, plural, =0{Brzy} =1{1 den} few{{count} dny} other{{count} dní}}",
|
||||
"trashDaysLeft": "{count, plural, =0{Brzy} =1{1 den} other{{count} dní}}",
|
||||
"@trashDaysLeft": {
|
||||
"description": "Text to indicate number of days remaining before permanent deletion",
|
||||
"placeholders": {
|
||||
@@ -900,7 +900,7 @@
|
||||
"unlock": "Odemknout",
|
||||
"freeUpSpace": "Uvolnit místo",
|
||||
"freeUpSpaceSaving": "{count, plural, =1 {Lze jej odstranit ze zařízení, aby se uvolnilo {formattedSize} místa} other {Lze je odstranit ze zařízení, aby se uvolnilo {formattedSize} místa}}",
|
||||
"filesBackedUpInAlbum": "{count, plural, one {1 soubor v tomto albu byl bezpečně zálohován} few {{formattedNumber} soubory v tomto albu byly bezpečně zálohovány} other {{formattedNumber} souborů v tomto albu bylo bezpečně zálohováno}}",
|
||||
"filesBackedUpInAlbum": "{count, plural, one {1 soubor v tomto albu byl bezpečně zálohován} other {{formattedNumber} souborů v tomto albu bylo bezpečně zálohováno}}",
|
||||
"@filesBackedUpInAlbum": {
|
||||
"description": "Text to tell user how many files have been backed up in the album",
|
||||
"placeholders": {
|
||||
@@ -915,7 +915,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"filesBackedUpFromDevice": "{count, plural, one {1 soubor na tomto zařízení byl bezpečně zálohován} few {{formattedNumber} soubory na tomto zařízení byly bezpečně zálohovány} other {{formattedNumber} souborů na tomto zařízení bylo bezpečně zálohováno}}",
|
||||
"filesBackedUpFromDevice": "{count, plural, one {1 soubor na tomto zařízení byl bezpečně zálohován} other {{formattedNumber} souborů na tomto zařízení bylo bezpečně zálohováno}}",
|
||||
"@filesBackedUpFromDevice": {
|
||||
"description": "Text to tell user how many files have been backed up from this device",
|
||||
"placeholders": {
|
||||
@@ -1217,7 +1217,7 @@
|
||||
"searchHint4": "Poloha",
|
||||
"searchHint5": "Již brzy: Kouzelné vyhledávání tváří ✨",
|
||||
"addYourPhotosNow": "Přidejte své fotografie nyní",
|
||||
"searchResultCount": "{count, plural, one{Nalezen {count} výsledek} few{Nalezeny {count} výsledky} other{Nalezeno {count} výsledků}}",
|
||||
"searchResultCount": "{count, plural, one{Nalezen {count} výsledek} other{Nalezeno {count} výsledků}}",
|
||||
"@searchResultCount": {
|
||||
"description": "Text to tell user how many results were found for their search query",
|
||||
"placeholders": {
|
||||
@@ -1403,7 +1403,7 @@
|
||||
"enableMachineLearningBanner": "Povolte strojové učení pro magické vyhledávání a rozpoznávání obličejů",
|
||||
"searchDiscoverEmptySection": "Obrázky se zde zobrazí po dokončení zpracování a synchronizace",
|
||||
"searchPersonsEmptySection": "Lidé se zde zobrazí po dokončení zpracování a synchronizace",
|
||||
"viewersSuccessfullyAdded": "{count, plural, =0 {Přidáno 0 pozorovatelů} =1 {Přidán 1 pozorovatel} few {Přidáni {count} pozorovatelé} other {Přidáno {count} pozorovatelů}}",
|
||||
"viewersSuccessfullyAdded": "{count, plural, =0 {Přidáno 0 pozorovatelů} =1 {Přidán 1 pozorovatel} other {Přidáno {count} pozorovatelů}}",
|
||||
"@viewersSuccessfullyAdded": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
@@ -1413,7 +1413,7 @@
|
||||
},
|
||||
"description": "Number of viewers that were successfully added to an album."
|
||||
},
|
||||
"collaboratorsSuccessfullyAdded": "{count, plural, =0 {Přidáno 0 spolupracovníků} =1 {Přidán 1 spolupracovník} few {Přidáni {count} spolupracovníci} other {Přidáno {count} spolupracovníků}}",
|
||||
"collaboratorsSuccessfullyAdded": "{count, plural, =0 {Přidáno 0 spolupracovníků} =1 {Přidán 1 spolupracovník} other {Přidáno {count} spolupracovníků}}",
|
||||
"@collaboratorsSuccessfullyAdded": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
@@ -1488,7 +1488,7 @@
|
||||
},
|
||||
"currentlyRunning": "aktuálně běží",
|
||||
"ignored": "ignorováno",
|
||||
"photosCount": "{count, plural, =0 {0 fotografií} =1 {1 fotografie} few {{count} fotografie} other {{count} fotografií}}",
|
||||
"photosCount": "{count, plural, =0 {0 fotografií} =1 {1 fotografie} other {{count} fotografií}}",
|
||||
"@photosCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
@@ -1686,7 +1686,7 @@
|
||||
"moveSelectedPhotosToOneDate": "Přesunout vybrané fotografie do jednoho data",
|
||||
"shiftDatesAndTime": "Posunout datum a čas",
|
||||
"photosKeepRelativeTimeDifference": "Fotografie zachovávají relativní časový rozdíl",
|
||||
"photocountPhotos": "{count, plural, =0 {Žádné fotografie} =1 {1 fotografie} few {{count} fotografie} other {{count} fotografií}}",
|
||||
"photocountPhotos": "{count, plural, =0 {Žádné fotografie} =1 {1 fotografie} other {{count} fotografií}}",
|
||||
"@photocountPhotos": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
@@ -1700,7 +1700,7 @@
|
||||
"selectedItemsWillBeRemovedFromThisPerson": "Vybrané položky budou z této osoby odebrány, ale nebudou smazány z vaší knihovny.",
|
||||
"throughTheYears": "{dateFormat} v průběhu let",
|
||||
"thisWeekThroughTheYears": "Tento týden v průběhu let",
|
||||
"thisWeekXYearsAgo": "{count, plural, =1 {Tento týden, {count} rok nazpět} few {Tento týden, {count} roky nazpět} other {Tento týden, {count} let nazpět}}",
|
||||
"thisWeekXYearsAgo": "{count, plural, =1 {Tento týden, {count} rok nazpět} other {Tento týden, {count} let nazpět}}",
|
||||
"youAndThem": "Vy a {name}",
|
||||
"admiringThem": "Obdiv k {name}",
|
||||
"embracingThem": "Objímání {name}",
|
||||
@@ -1776,6 +1776,14 @@
|
||||
"same": "Stejné",
|
||||
"different": "Odlišné",
|
||||
"sameperson": "Stejná osoba?",
|
||||
"cLTitle1": "Pokročilý editor obrázků",
|
||||
"cLDesc1": "Vydáváme nový a pokročilý editor obrázků, který přidává více ořezových rámečků, přednastavené filtry pro rychlé úpravy, možnosti jemného doladění včetně sytosti, kontrastu, jasu, teploty a mnoho dalšího. Nový editor také zahrnuje možnost kreslit na vaše fotografie a přidávat emodži jako nálepky.",
|
||||
"cLTitle2": "Chytrá alba",
|
||||
"cLDesc2": "Nyní můžete automaticky přidávat fotografie vybraných osob do libovolného alba. Stačí přejít do alba a v rozbalovací nabídce vybrat možnost „Automaticky přidat osoby“. Pokud tuto funkci použijete společně se sdíleným albem, můžete sdílet fotografie bez jediného kliknutí.",
|
||||
"cLTitle3": "Vylepšená galerie",
|
||||
"cLDesc3": "Přidali jsme možnost seskupit vaši galerii podle týdnů, měsíců a let. Nyní můžete svou galerii přizpůsobit tak, aby vypadala přesně podle vašich představ, a to díky těmto novým možnostem seskupování a přizpůsobitelným mřížkám",
|
||||
"cLTitle4": "Rychlejší posouvání",
|
||||
"cLDesc4": "Kromě řady vylepšení pod kapotou, která zlepšují procházení galerií, jsme také přepracovali posuvník tak, aby zobrazoval značky, díky nimž můžete rychle přeskakovat po časové ose.",
|
||||
"indexingPausedStatusDescription": "Indexování je pozastaveno. Automaticky se obnoví, jakmile bude zařízení připraveno. Zařízení je považováno za připravené, pokud jsou úroveň nabití baterie, stav baterie a teplotní stav v normálním rozmezí.",
|
||||
"thisWeek": "Tento týden",
|
||||
"lastWeek": "Minulý týden",
|
||||
@@ -1823,8 +1831,6 @@
|
||||
"videosProcessed": "Zpracovaná videa",
|
||||
"totalVideos": "Celkový počet videí",
|
||||
"skippedVideos": "Přeskočená videa",
|
||||
"videoStreamingDescriptionLine1": "Přehrávejte videa okamžitě na jakémkoli zařízení.",
|
||||
"videoStreamingDescriptionLine2": "Povolte pro zpracování video streamů na tomto zařízení.",
|
||||
"videoStreamingNote": "Na tomto zařízení se zpracovávají pouze videa z posledních 60 dnů, která jsou kratší než 1 minuta. U starších/delších videí povolte streamování v desktopové aplikaci.",
|
||||
"createStream": "Vytvořit stream",
|
||||
"recreateStream": "Obnovit stream",
|
||||
@@ -1838,7 +1844,7 @@
|
||||
"findSimilarImages": "Najít podobné obrázky",
|
||||
"noSimilarImagesFound": "Nebyly nalezeny žádné podobné obrázky",
|
||||
"yourPhotosLookUnique": "Vaše fotografie vypadají jedinečně",
|
||||
"similarGroupsFound": "{count, plural, =1{{count} skupina nalezena} few{{count} skupiny nalezeny} other{{count} skupin nalezeno}}",
|
||||
"similarGroupsFound": "{count, plural, =1{{count} skupina nalezena} other{{count} skupin nalezeno}}",
|
||||
"@similarGroupsFound": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
@@ -1907,29 +1913,12 @@
|
||||
"deleteFiles": "Smazat soubory",
|
||||
"areYouSureDeleteFiles": "Opravdu chcete tyto soubory smazat?",
|
||||
"greatJob": "Dobrá práce!",
|
||||
"cleanedUpSimilarImages": "Uvolnili jste {size} místa",
|
||||
"@cleanedUpSimilarImages": {
|
||||
"placeholders": {
|
||||
"size": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"size": "Velikost",
|
||||
"similarity": "Podobnost",
|
||||
"analyzingPhotosLocally": "Analyzuji vaše fotografie lokálně...",
|
||||
"lookingForVisualSimilarities": "Hledání vizuálních podobností...",
|
||||
"comparingImageDetails": "Porovnávání detailů obrázku...",
|
||||
"findingSimilarImages": "Hledání podobných obrázků...",
|
||||
"almostDone": "Téměř hotovo...",
|
||||
"processingLocally": "Místní zpracování",
|
||||
"useMLToFindSimilarImages": "Zkontrolujte a odstraňte obrázky, které se navzájem podobají.",
|
||||
"all": "Vše",
|
||||
"similar": "Podobné",
|
||||
"identical": "Identické",
|
||||
"nothingHereTryAnotherFilter": "Tady nic není, zkuste jiný filtr! 👀",
|
||||
"related": "Související",
|
||||
"hoorayyyy": "Huráááá!",
|
||||
"nothingToTidyUpHere": "Tady není co uklízet",
|
||||
"deletingDash": "Mazání - "
|
||||
}
|
||||
"nothingHereTryAnotherFilter": "Tady nic není, zkuste jiný filtr! 👀"
|
||||
}
|
||||
|
||||
@@ -207,16 +207,6 @@
|
||||
"after1Month": "Efter 1 måned",
|
||||
"after1Year": "Efter 1 år",
|
||||
"manageParticipants": "Administrer",
|
||||
"albumParticipantsCount": "{count, plural, =0 {Ingen Deltagere} =1 {1 Deltager} other {{count} Deltagere}}",
|
||||
"@albumParticipantsCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int",
|
||||
"example": "5"
|
||||
}
|
||||
},
|
||||
"description": "Number of participants in an album, including the album owner."
|
||||
},
|
||||
"collabLinkSectionDescription": "Opret et link, så folk kan tilføje og se fotos i dit delte album uden at behøve en Ente-app eller konto. Fantastisk til at indsamle event fotos.",
|
||||
"collectPhotos": "Indsaml billeder",
|
||||
"collaborativeLink": "Kollaborativt link",
|
||||
|
||||
@@ -1776,6 +1776,14 @@
|
||||
"same": "Gleich",
|
||||
"different": "Verschieden",
|
||||
"sameperson": "Dieselbe Person?",
|
||||
"cLTitle1": "Erweiterte Bildbearbeitung",
|
||||
"cLDesc1": "Wir veröffentlichen eine neue und erweiterte Bildbearbeitung, die mehr Bildzuschnitte ermöglicht, vordefinierte Filter für schnelleres Bearbeiten bietet, sowie die Feinabstimmung von Sättigung, Kontrast, Helligkeit und vielem mehr erlaubt. Der neue Editor erlaubt außerdem das Zeichnen auf den Fotos oder das Hinzufügen von Emojis als Sticker.",
|
||||
"cLTitle2": "Intelligente Alben",
|
||||
"cLDesc2": "Du kannst jetzt automatisch Fotos von ausgewählten Personen zu jedem Album hinzufügen. Öffne einfach das Album und wähle \"Personen automatisch hinzufügen\" aus dem Menü. Zusammen mit einem geteilten Album kannst Du Fotos mit null Klicks teilen.",
|
||||
"cLTitle3": "Verbesserte Galerie",
|
||||
"cLDesc3": "Wir haben die Möglichkeit hinzugefügt, Alben nach Wochen, Monaten und Jahren zu gruppieren. Du kannst jetzt die Galerie mit diesen neuen Gruppierungs-Optionen so anpassen, dass sie genau so aussieht, wie Du möchtest, zusammen mit angepassten Rastern",
|
||||
"cLTitle4": "Schnelleres Scrollen",
|
||||
"cLDesc4": "Zusammen mit einem Schwung Änderungen unter der Haube, um das Erlebnis beim Scrollen der Galerie zu verbessern, haben wir außerdem den Scrollbalken mit Markern neu gestaltet, um es Dir zu ermöglichen, schnell in der Zeitleiste zu springen.",
|
||||
"indexingPausedStatusDescription": "Die Indizierung ist pausiert. Sie wird automatisch fortgesetzt, wenn das Gerät bereit ist. Das Gerät wird als bereit angesehen, wenn sich der Akkustand, die Akkugesundheit und der thermische Zustand in einem gesunden Bereich befinden.",
|
||||
"thisWeek": "Diese Woche",
|
||||
"lastWeek": "Letzte Woche",
|
||||
@@ -1917,11 +1925,6 @@
|
||||
},
|
||||
"size": "Größe",
|
||||
"similarity": "Ähnlichkeit",
|
||||
"analyzingPhotosLocally": "Analysiere deine Fotos lokal...",
|
||||
"lookingForVisualSimilarities": "Suche nach visuellen Ähnlichkeiten...",
|
||||
"comparingImageDetails": "Vergleiche Bilddetails...",
|
||||
"findingSimilarImages": "Finde ähnliche Bilder...",
|
||||
"almostDone": "Fast fertig...",
|
||||
"processingLocally": "Lokale Verarbeitung",
|
||||
"useMLToFindSimilarImages": "Überprüfe und entferne Bilder, die sich ähnlich sehen.",
|
||||
"all": "Alle",
|
||||
@@ -1930,12 +1933,5 @@
|
||||
"nothingHereTryAnotherFilter": "Nichts zu sehen, probier einen anderen Filter! 👀",
|
||||
"related": "Verwandt",
|
||||
"hoorayyyy": "Hurraaaa!",
|
||||
"nothingToTidyUpHere": "Hier gibt es nichts zu bereinigen",
|
||||
"deletingDash": "Lösche - ",
|
||||
"cLTitle1": "Ähnliche Bilder",
|
||||
"cLDesc1": "Wir führen ein neues ML-basiertes System ein, um ähnliche Bilder zu erkennen, mit dem du deine Bibliothek bereinigen kannst. Verfügbar unter Einstellungen -> Backup -> Speicherplatz freigeben",
|
||||
"cLTitle2": "Verbesserungen für Video-Streaming",
|
||||
"cLDesc2": "Du kannst jetzt die Erstellung von Videos manuell direkt aus der App auslösen. Wir haben auch einen neuen Einstellungsbildschirm für das Video-Streaming hinzugefügt, der dir zeigen wird, welcher Prozentsatz deiner Videos für Streaming verarbeitet wurde",
|
||||
"cLTitle3": "Leistungsverbesserungen",
|
||||
"cLDesc3": "Zahlreiche Verbesserungen unter der Haube, u.a. bessere Nutzung des Cache und ein sanfteres Scroll-Erlebnis"
|
||||
"nothingToTidyUpHere": "Hier gibt es nichts zu bereinigen"
|
||||
}
|
||||
@@ -1776,6 +1776,14 @@
|
||||
"same": "Same",
|
||||
"different": "Different",
|
||||
"sameperson": "Same person?",
|
||||
"cLTitle1": "Advanced Image Editor",
|
||||
"cLDesc1": "We are releasing a new and advanced image editor that add more cropping frames, filter presets for quick edits, fine tuning options including saturation, contrast, brightness, temperature and a lot more. The new editor also includes the ability to draw on your photos and add emojis as stickers.",
|
||||
"cLTitle2": "Smart Albums",
|
||||
"cLDesc2": "You can now automatically add photos of selected people to any album. Just go the album, and select \"auto-add people\" from the overflow menu. If used along with shared album, you can share photos with zero clicks.",
|
||||
"cLTitle3": "Improved Gallery",
|
||||
"cLDesc3": "We have added the ability to group your gallery by weeks, months, and years. You can now customise your gallery to look exactly the way you want with these new grouping options, along with custom grids",
|
||||
"cLTitle4": "Faster Scroll",
|
||||
"cLDesc4": "Along with a bunch of under the hood improvements to improve the gallery scroll experience, we have also redesigned the scroll bar to show markers, allowing you to quickly jump across the timeline.",
|
||||
"indexingPausedStatusDescription": "Indexing is paused. It will automatically resume when the device is ready. The device is considered ready when its battery level, battery health, and thermal status are within a healthy range.",
|
||||
"thisWeek": "This week",
|
||||
"lastWeek": "Last week",
|
||||
@@ -1931,11 +1939,5 @@
|
||||
"related": "Related",
|
||||
"hoorayyyy": "Hoorayyyy!",
|
||||
"nothingToTidyUpHere": "Nothing to tidy up here",
|
||||
"deletingDash": "Deleting - ",
|
||||
"cLTitle1": "Similar images",
|
||||
"cLDesc1": "We are introducing a new ML-based system to detect similar images, using which you can cleanup your library. Available in Settings -> Backup -> Free up space",
|
||||
"cLTitle2": "Video streaming enhancements",
|
||||
"cLDesc2": "You can now manually trigger stream generation for videos directly from the app. We have also added a new video streaming settings screen which will show you what percentage of your videos have been processed for streaming",
|
||||
"cLTitle3": "Performance improvements",
|
||||
"cLDesc3": "Multiple under the hood improvements, including better cache usage and a smoother scroll experience"
|
||||
"deletingDash": "Deleting - "
|
||||
}
|
||||
|
||||
@@ -1776,6 +1776,14 @@
|
||||
"same": "Igual",
|
||||
"different": "Diferente",
|
||||
"sameperson": "la misma persona?",
|
||||
"cLTitle1": "Editor avanzado de imágenes",
|
||||
"cLDesc1": "Estamos lanzando un nuevo y avanzado editor de imágenes que añade más marcos de recorte, preajustes de filtros para edición rápida, opciones de ajuste finas incluyendo saturación, contraste, brillo, temperatura y mucho más. El nuevo editor también incluye la capacidad de dibujar en tus fotos y añadir emojis como pegatinas.",
|
||||
"cLTitle2": "Álbumes Inteligentes",
|
||||
"cLDesc2": "Ahora puedes añadir automáticamente fotos de personas seleccionadas a cualquier álbum. Solo tienes que ir al álbum, y seleccionar \"Agregar personas automáticamente\" del menú desbordante. Si se utiliza junto con el álbum compartido, puedes compartir fotos con cero clics.",
|
||||
"cLTitle3": "Galería mejorada",
|
||||
"cLDesc3": "Hemos añadido la capacidad de agrupar tu galería por semanas, meses y años. Ahora puedes personalizar tu galería exactamente como quieras con estas nuevas opciones de agrupación, junto con rejillas personalizadas",
|
||||
"cLTitle4": "Desplazamiento más rápido",
|
||||
"cLDesc4": "Junto con un montón de mejoras bajo el capó para mejorar la experiencia del desplazamiento de la galería también hemos rediseñado la barra de desplazamiento para mostrar los marcadores, permitiéndote saltar rápidamente a través de la línea de tiempo.",
|
||||
"indexingPausedStatusDescription": "La indexación está pausada. Se reanudará automáticamente cuando el dispositivo esté listo. El dispositivo se considera listo cuando su nivel de batería, la salud de la batería y temperatura están en un rango saludable.",
|
||||
"thisWeek": "Esta semana",
|
||||
"lastWeek": "Semana pasada",
|
||||
@@ -1811,7 +1819,7 @@
|
||||
"font": "Fuente",
|
||||
"background": "Fondo",
|
||||
"align": "Alinear",
|
||||
"addedToAlbums": "{count, plural, one {}=1{Añadido con éxito a 1 álbum} other{Añadido con éxito a {count} álbumes}}",
|
||||
"addedToAlbums": "{count, plural, =1{Añadido con éxito a 1 álbum} other{Añadido con éxito a {count} álbumes}}",
|
||||
"@addedToAlbums": {
|
||||
"description": "Message shown when items are added to albums",
|
||||
"placeholders": {
|
||||
@@ -1838,7 +1846,7 @@
|
||||
"findSimilarImages": "Buscar imágenes similares",
|
||||
"noSimilarImagesFound": "No se encontraron imágenes similares",
|
||||
"yourPhotosLookUnique": "Tus fotos se ven únicas",
|
||||
"similarGroupsFound": "{count, plural, one {}=1{{count} grupo encontrado} other{{count} grupos encontrados}}",
|
||||
"similarGroupsFound": "{count, plural, =1{{count} grupo encontrado} other{{count} grupos encontrados}}",
|
||||
"@similarGroupsFound": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
@@ -1917,11 +1925,6 @@
|
||||
},
|
||||
"size": "Tamaño",
|
||||
"similarity": "Similitud",
|
||||
"analyzingPhotosLocally": "Analizando tus fotos localmente...",
|
||||
"lookingForVisualSimilarities": "Buscando similitudes visuales...",
|
||||
"comparingImageDetails": "Comparando detalles de imágenes...",
|
||||
"findingSimilarImages": "Buscando imágenes similares...",
|
||||
"almostDone": "Ya casi terminamos...",
|
||||
"processingLocally": "Procesando localmente",
|
||||
"useMLToFindSimilarImages": "Revisar y eliminar imágenes que se parecen entre sí.",
|
||||
"all": "Todas",
|
||||
@@ -1930,12 +1933,5 @@
|
||||
"nothingHereTryAnotherFilter": "Nada aquí, ¡prueba con otro filtro! 👀",
|
||||
"related": "Relacionado",
|
||||
"hoorayyyy": "¡Hurraaaa!",
|
||||
"nothingToTidyUpHere": "Nada que limpiar aquí",
|
||||
"deletingDash": "Eliminando - ",
|
||||
"cLTitle1": "Imágenes similares",
|
||||
"cLDesc1": "Estamos introduciendo un nuevo sistema basado en ML para detectar imágenes similares, utilizando el cual puede limpiar tu biblioteca. Disponible en Ajustes -> Copia de seguridad -> Liberar espacio",
|
||||
"cLTitle2": "Mejoras de transmisión de vídeo",
|
||||
"cLDesc2": "Ahora puedes activar manualmente la generación de transmisión de vídeos directamente desde la aplicación. También hemos añadido una nueva pantalla de configuración de transmisión de vídeo que te mostrará qué porcentaje de tus vídeos han sido procesados para la transmisión",
|
||||
"cLTitle3": "Mejoras de rendimiento",
|
||||
"cLDesc3": "Múltiples mejoras bajo la capa, incluyendo un mejor uso de caché y una experiencia de desplazamiento más suave"
|
||||
}
|
||||
"nothingToTidyUpHere": "Nada que limpiar aquí"
|
||||
}
|
||||
|
||||
@@ -899,7 +899,7 @@
|
||||
"authToViewYourMemories": "Authentifiez-vous pour voir vos souvenirs",
|
||||
"unlock": "Déverrouiller",
|
||||
"freeUpSpace": "Libérer de l'espace",
|
||||
"freeUpSpaceSaving": "{count, plural, one {}=1 {Il peut être supprimé de l'appareil pour libérer {formattedSize}} other {Ils peuvent être supprimés de l'appareil pour libérer {formattedSize}}}",
|
||||
"freeUpSpaceSaving": "{count, plural, =1 {Il peut être supprimé de l'appareil pour libérer {formattedSize}} other {Ils peuvent être supprimés de l'appareil pour libérer {formattedSize}}}",
|
||||
"filesBackedUpInAlbum": "{count, plural, one {1 fichier dans cet album a été sauvegardé en toute sécurité} other {{formattedNumber} fichiers dans cet album ont été sauvegardés en toute sécurité}}",
|
||||
"@filesBackedUpInAlbum": {
|
||||
"description": "Text to tell user how many files have been backed up in the album",
|
||||
@@ -933,7 +933,7 @@
|
||||
"@freeUpSpaceSaving": {
|
||||
"description": "Text to tell user how much space they can free up by deleting items from the device"
|
||||
},
|
||||
"freeUpAccessPostDelete": "Vous pouvez toujours {count, plural, one {}=1 {l'} other {les}} accéder sur Ente tant que vous avez un abonnement actif",
|
||||
"freeUpAccessPostDelete": "Vous pouvez toujours {count, plural, =1 {l'} other {les}} accéder sur Ente tant que vous avez un abonnement actif",
|
||||
"@freeUpAccessPostDelete": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
@@ -1269,8 +1269,8 @@
|
||||
"description": "Subtitle to indicate that the user can find people quickly by name"
|
||||
},
|
||||
"findPeopleByName": "Trouver des personnes rapidement par leur nom",
|
||||
"addViewers": "{count, plural, one {}=0 {Ajouter un spectateur} =1 {Ajouter une spectateur} other {Ajouter des spectateurs}}",
|
||||
"addCollaborators": "{count, plural, one {}=0 {Ajouter un collaborateur} =1 {Ajouter un collaborateur} other {Ajouter des collaborateurs}}",
|
||||
"addViewers": "{count, plural, =0 {Ajouter un spectateur} =1 {Ajouter une spectateur} other {Ajouter des spectateurs}}",
|
||||
"addCollaborators": "{count, plural, =0 {Ajouter un collaborateur} =1 {Ajouter un collaborateur} other {Ajouter des collaborateurs}}",
|
||||
"longPressAnEmailToVerifyEndToEndEncryption": "Appuyez longuement sur un email pour vérifier le chiffrement de bout en bout.",
|
||||
"developerSettingsWarning": "Êtes-vous sûr de vouloir modifier les paramètres du développeur ?",
|
||||
"developerSettings": "Paramètres du développeur",
|
||||
@@ -1403,7 +1403,7 @@
|
||||
"enableMachineLearningBanner": "Activer l'apprentissage automatique pour la reconnaissance des visages et la recherche magique",
|
||||
"searchDiscoverEmptySection": "Les images seront affichées ici une fois le traitement terminé",
|
||||
"searchPersonsEmptySection": "Les personnes seront affichées ici une fois le traitement terminé",
|
||||
"viewersSuccessfullyAdded": "{count, plural, one {}=0 {0 spectateur ajouté} =1 {Un spectateur ajouté} other {{count} spectateurs ajoutés}}",
|
||||
"viewersSuccessfullyAdded": "{count, plural, =0 {0 spectateur ajouté} =1 {Un spectateur ajouté} other {{count} spectateurs ajoutés}}",
|
||||
"@viewersSuccessfullyAdded": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
@@ -1776,6 +1776,14 @@
|
||||
"same": "Identique",
|
||||
"different": "Différent(e)",
|
||||
"sameperson": "Même personne ?",
|
||||
"cLTitle1": "Éditeur d'image avancé",
|
||||
"cLDesc1": "Nous déployons un nouvel éditeur d'image avancé qui ajoute plus d'options de rognage, des filtres, des préréglages pour des modifications rapides ainsi que des options de réglage fin (la saturation, le contraste, la luminosité, la température et beaucoup plus). Le nouvel éditeur inclut également la possibilité de dessiner sur vos photos et d'ajouter des emojis en tant qu'autocollants.",
|
||||
"cLTitle2": "Albums Intelligents",
|
||||
"cLDesc2": "Vous pouvez maintenant ajouter automatiquement des photos de personnes sélectionnées à n'importe quel album. Allez simplement à l'album et sélectionnez \"Ajouter automatiquement des personnes\" dans le menu déroulant. Couplé avec un album partagé, vous pouvez partager des photos en zéro clic.",
|
||||
"cLTitle3": "Galerie améliorée",
|
||||
"cLDesc3": "Nous avons ajouté la possibilité de regrouper votre galerie par semaines, mois et années. Vous pouvez maintenant personnaliser votre galerie pour qu'elle soit exactement comme vous le souhaitez avec ces nouvelles options de regroupement, ainsi que des grilles personnalisées",
|
||||
"cLTitle4": "Défilement plus rapide",
|
||||
"cLDesc4": "En plus des quelques améliorations pour améliorer l'expérience de défilement de la galerie, nous avons également redessiné la barre de défilement pour afficher des marqueurs, ce qui vous permet de sauter rapidement dans la chronologie.",
|
||||
"indexingPausedStatusDescription": "L'indexation est en pause. Elle reprendra automatiquement lorsque l'appareil sera prêt. Celui-ci est considéré comme prêt lorsque le niveau de batterie, sa santé et son état thermique sont dans une plage saine.",
|
||||
"thisWeek": "Cette semaine",
|
||||
"lastWeek": "La semaine dernière",
|
||||
@@ -1811,7 +1819,7 @@
|
||||
"font": "Police",
|
||||
"background": "Arrière-plan",
|
||||
"align": "Aligner",
|
||||
"addedToAlbums": "{count, plural, one {}=1{Ajouté avec succès à 1 album} other{Ajouté avec succès à {count} albums}}",
|
||||
"addedToAlbums": "{count, plural, =1{Ajouté avec succès à 1 album} other{Ajouté avec succès à {count} albums}}",
|
||||
"@addedToAlbums": {
|
||||
"description": "Message shown when items are added to albums",
|
||||
"placeholders": {
|
||||
@@ -1909,11 +1917,6 @@
|
||||
},
|
||||
"size": "Taille",
|
||||
"similarity": "Similitude",
|
||||
"analyzingPhotosLocally": "Analyse de vos photos en local ...",
|
||||
"lookingForVisualSimilarities": "Recherche de ressemblances visuelles...",
|
||||
"comparingImageDetails": "Comparaison des détails de l'image...",
|
||||
"findingSimilarImages": "Recherche d'images similaires...",
|
||||
"almostDone": "C'est presque terminé...",
|
||||
"processingLocally": "Traitement local",
|
||||
"useMLToFindSimilarImages": "Examinez et supprimez les images qui se ressemblent entre elles.",
|
||||
"all": "Toutes",
|
||||
@@ -1922,12 +1925,5 @@
|
||||
"nothingHereTryAnotherFilter": "Rien ici, essayez un autre filtre ! 👀",
|
||||
"related": "Liés",
|
||||
"hoorayyyy": "Houraaa !",
|
||||
"nothingToTidyUpHere": "Rien à nettoyer ici",
|
||||
"deletingDash": "Suppression - ",
|
||||
"cLTitle1": "Images similaires",
|
||||
"cLDesc1": "Nous introduisons un nouveau système basé sur le ML pour détecter des images similaires, en utilisant lesquelles vous pouvez nettoyer de votre bibliothèque. Disponible dans Paramètres->Sauvegarde->Espace libre",
|
||||
"cLTitle2": "Améliorations du streaming vidéo",
|
||||
"cLDesc2": "Vous pouvez maintenant déclencher manuellement la génération de flux vidéos directement depuis l'application. Nous avons également ajouté un nouvel écran de paramètres de streaming vidéo qui vous montrera quel pourcentage de vos vidéos ont été traitées pour le streaming",
|
||||
"cLTitle3": "Amélioration des performances",
|
||||
"cLDesc3": "Plusieurs améliorations invisibles, y compris une meilleure utilisation du cache et une expérience de défilement plus fluide"
|
||||
}
|
||||
"nothingToTidyUpHere": "Rien à nettoyer ici"
|
||||
}
|
||||
|
||||
@@ -389,8 +389,8 @@
|
||||
"selectAll": "בחר הכל",
|
||||
"skip": "דלג",
|
||||
"updatingFolderSelection": "מעדכן את בחירת התיקיות...",
|
||||
"itemCount": "{count, plural, one{{count} פריט} two {{count} פריטים} many {{count} פריטים} other{{count} פריטים}}",
|
||||
"deleteItemCount": "{count, plural, =1 {מחק {count} פריט} two {מחק {count} פריטים} other {מחק {count} פריטים}}",
|
||||
"itemCount": "{count, plural, one{{count} פריט} other{{count} פריטים}}",
|
||||
"deleteItemCount": "{count, plural, =1 {מחק {count} פריט} other {מחק {count} פריטים}}",
|
||||
"duplicateItemsGroup": "{count} קבצים, כל אחד {formattedSize}",
|
||||
"@duplicateItemsGroup": {
|
||||
"description": "Display the number of duplicate files and their size",
|
||||
@@ -407,7 +407,7 @@
|
||||
}
|
||||
},
|
||||
"showMemories": "הצג זכרונות",
|
||||
"yearsAgo": "{count, plural, one{לפני {count} שנה} two {לפני {count} שנים} many {לפני {count} שנים} other{לפני {count} שנים}}",
|
||||
"yearsAgo": "{count, plural, one{לפני {count} שנה} other{לפני {count} שנים}}",
|
||||
"backupSettings": "הגדרות גיבוי",
|
||||
"backupOverMobileData": "גבה על רשת סלולרית",
|
||||
"backupVideos": "גבה סרטונים",
|
||||
@@ -792,4 +792,4 @@
|
||||
"create": "צור",
|
||||
"viewAll": "הצג הכל",
|
||||
"hiding": "מחביא..."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -458,7 +458,7 @@
|
||||
"selectAll": "Összes kijelölése",
|
||||
"skip": "Kihagyás",
|
||||
"updatingFolderSelection": "Mappakijelölés frissítése...",
|
||||
"itemCount": "{count, plural, one{{count} elem} other{{count} elem}}",
|
||||
"itemCount": "{count, plural, other{{count} elem}}",
|
||||
"deleteItemCount": "{count, plural, =1 {Elem {count} törlése} other {Elemek {count} törlése}}",
|
||||
"duplicateItemsGroup": "{count} fájl, {formattedSize} mindegyik",
|
||||
"@duplicateItemsGroup": {
|
||||
@@ -541,4 +541,4 @@
|
||||
}
|
||||
},
|
||||
"remindToEmptyEnteTrash": "Ürítsd ki a \"Kukát\" is, hogy visszaszerezd a felszabadult helyet."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -372,7 +372,7 @@
|
||||
"deleteFromBoth": "Hapus dari keduanya",
|
||||
"newAlbum": "Album baru",
|
||||
"albums": "Album",
|
||||
"memoryCount": "{count, plural, =0{tidak ada memori} one{{formattedCount} memori} other{{formattedCount} memori}}",
|
||||
"memoryCount": "{count, plural, =0{tidak ada memori} other{{formattedCount} memori}}",
|
||||
"@memoryCount": {
|
||||
"description": "The text to display the number of memories",
|
||||
"type": "text",
|
||||
@@ -1234,4 +1234,4 @@
|
||||
"left": "Kiri",
|
||||
"right": "Kanan",
|
||||
"whatsNew": "Hal yang baru"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -794,11 +794,11 @@
|
||||
"share": "Condividi",
|
||||
"unhideToAlbum": "Non nascondere l'album",
|
||||
"restoreToAlbum": "Ripristina l'album",
|
||||
"moveItem": "{count, plural, one {}=1 {Sposta elemento} other {Sposta elementi}}",
|
||||
"moveItem": "{count, plural, =1 {Sposta elemento} other {Sposta elementi}}",
|
||||
"@moveItem": {
|
||||
"description": "Page title while moving one or more items to an album"
|
||||
},
|
||||
"addItem": "{count, plural, one {}=1 {Aggiungi elemento} other {Aggiungi elementi}}",
|
||||
"addItem": "{count, plural, =1 {Aggiungi elemento} other {Aggiungi elementi}}",
|
||||
"@addItem": {
|
||||
"description": "Page title while adding one or more items to album"
|
||||
},
|
||||
@@ -899,7 +899,7 @@
|
||||
"authToViewYourMemories": "Autenticati per visualizzare le tue foto",
|
||||
"unlock": "Sblocca",
|
||||
"freeUpSpace": "Libera spazio",
|
||||
"freeUpSpaceSaving": "{count, plural, one {}=1 {Può essere cancellato per liberare {formattedSize}} other {Possono essere cancellati per liberare {formattedSize}}}",
|
||||
"freeUpSpaceSaving": "{count, plural, =1 {Può essere cancellato per liberare {formattedSize}} other {Possono essere cancellati per liberare {formattedSize}}}",
|
||||
"filesBackedUpInAlbum": "{count, plural, one {1 file} other {{formattedNumber} file}} di quest'album sono stati salvati in modo sicuro",
|
||||
"@filesBackedUpInAlbum": {
|
||||
"description": "Text to tell user how many files have been backed up in the album",
|
||||
@@ -1260,8 +1260,8 @@
|
||||
"description": "Subtitle to indicate that the user can find people quickly by name"
|
||||
},
|
||||
"findPeopleByName": "Trova rapidamente le persone per nome",
|
||||
"addViewers": "{count, plural, one {}=0 {Aggiungi visualizzatore} =1 {Add viewer} other {Aggiungi visualizzatori}}",
|
||||
"addCollaborators": "{count, plural, one {}=0 {Aggiungi collaboratore} =1 {Aggiungi collaboratore} other {Aggiungi collaboratori}}",
|
||||
"addViewers": "{count, plural, =0 {Aggiungi visualizzatore} =1 {Add viewer} other {Aggiungi visualizzatori}}",
|
||||
"addCollaborators": "{count, plural, =0 {Aggiungi collaboratore} =1 {Aggiungi collaboratore} other {Aggiungi collaboratori}}",
|
||||
"longPressAnEmailToVerifyEndToEndEncryption": "Premi a lungo un'email per verificare la crittografia end to end.",
|
||||
"developerSettingsWarning": "Sei sicuro di voler modificare le Impostazioni sviluppatore?",
|
||||
"developerSettings": "Impostazioni sviluppatore",
|
||||
@@ -1394,7 +1394,7 @@
|
||||
"enableMachineLearningBanner": "Abilita l'apprendimento automatico per la ricerca magica e il riconoscimento facciale",
|
||||
"searchDiscoverEmptySection": "Le immagini saranno mostrate qui una volta che l'elaborazione e la sincronizzazione saranno completate",
|
||||
"searchPersonsEmptySection": "Le persone saranno mostrate qui una volta che l'elaborazione e la sincronizzazione saranno completate",
|
||||
"viewersSuccessfullyAdded": "{count, plural, one {}=0 {Added 0 visualizzatori} =1 {Added 1 visualizzatore} other {Added {count} visualizzatori}}",
|
||||
"viewersSuccessfullyAdded": "{count, plural, =0 {Added 0 visualizzatori} =1 {Added 1 visualizzatore} other {Added {count} visualizzatori}}",
|
||||
"@viewersSuccessfullyAdded": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
@@ -1479,7 +1479,7 @@
|
||||
},
|
||||
"currentlyRunning": "attualmente in esecuzione",
|
||||
"ignored": "ignorato",
|
||||
"photosCount": "{count, plural, one {}=0 {0 foto} =1 {1 foto} other {{count} foto}}",
|
||||
"photosCount": "{count, plural, =0 {0 foto} =1 {1 foto} other {{count} foto}}",
|
||||
"@photosCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
@@ -1677,7 +1677,7 @@
|
||||
"moveSelectedPhotosToOneDate": "Sposta foto selezionate in una data specifica",
|
||||
"shiftDatesAndTime": "Sposta date e orari",
|
||||
"photosKeepRelativeTimeDifference": "Le foto mantengono una differenza di tempo relativa",
|
||||
"photocountPhotos": "{count, plural, one {}=0 {Nessuna foto} =1 {1 foto} other {{count} foto}}",
|
||||
"photocountPhotos": "{count, plural, =0 {Nessuna foto} =1 {1 foto} other {{count} foto}}",
|
||||
"@photocountPhotos": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
@@ -1691,7 +1691,7 @@
|
||||
"selectedItemsWillBeRemovedFromThisPerson": "Gli elementi selezionati verranno rimossi da questa persona, ma non eliminati dalla tua libreria.",
|
||||
"throughTheYears": "{dateFormat} negli anni",
|
||||
"thisWeekThroughTheYears": "Questa settimana negli anni",
|
||||
"thisWeekXYearsAgo": "{count, plural, one {}=1 {Questa settimana, {count} anno fa} other {Questa settimana, {count} anni fa}}",
|
||||
"thisWeekXYearsAgo": "{count, plural, =1 {Questa settimana, {count} anno fa} other {Questa settimana, {count} anni fa}}",
|
||||
"youAndThem": "Tu e {name}",
|
||||
"admiringThem": "Ammirando {name}",
|
||||
"embracingThem": "Abbracciando {name}",
|
||||
@@ -1746,4 +1746,4 @@
|
||||
"receiveRemindersOnBirthdays": "Ricevi promemoria quando è il compleanno di qualcuno. Toccare la notifica ti porterà alle foto della persona che compie gli anni.",
|
||||
"happyBirthday": "Buon compleanno! 🥳",
|
||||
"birthdays": "Compleanni"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -461,7 +461,7 @@
|
||||
}
|
||||
},
|
||||
"showMemories": "思い出を表示",
|
||||
"yearsAgo": "{count, plural, one{{count} 年前} other{{count} 年前}}",
|
||||
"yearsAgo": "{count, plural, other{{count} 年前}}",
|
||||
"backupSettings": "バックアップ設定",
|
||||
"backupStatus": "バックアップの状態",
|
||||
"backupStatusDescription": "バックアップされたアイテムがここに表示されます",
|
||||
@@ -527,7 +527,7 @@
|
||||
},
|
||||
"remindToEmptyEnteTrash": "「ゴミ箱」も空にするとアカウントのストレージが解放されます",
|
||||
"sparkleSuccess": "成功✨",
|
||||
"duplicateFileCountWithStorageSaved": "お掃除しました {count, plural, one{{count} 個の重複ファイル} other{{count} 個の重複ファイル}}, ({storageSaved}が開放されます!)",
|
||||
"duplicateFileCountWithStorageSaved": "お掃除しました {count, plural, other{{count} 個の重複ファイル}}, ({storageSaved}が開放されます!)",
|
||||
"@duplicateFileCountWithStorageSaved": {
|
||||
"description": "The text to display when the user has successfully cleaned up duplicate files",
|
||||
"type": "text",
|
||||
@@ -1178,7 +1178,7 @@
|
||||
"searchHint4": "場所",
|
||||
"searchHint5": "近日公開: フェイスとマジック検索 ✨",
|
||||
"addYourPhotosNow": "写真を今すぐ追加する",
|
||||
"searchResultCount": "{count, plural, one{{count} 個の結果} other{{count} 個の結果}}",
|
||||
"searchResultCount": "{count, plural, other{{count} 個の結果}}",
|
||||
"@searchResultCount": {
|
||||
"description": "Text to tell user how many results were found for their search query",
|
||||
"placeholders": {
|
||||
@@ -1666,4 +1666,4 @@
|
||||
"onTheRoad": "再び道で",
|
||||
"food": "料理を楽しむ",
|
||||
"pets": "毛むくじゃらな仲間たち"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -794,11 +794,7 @@
|
||||
"share": "Bendrinti",
|
||||
"unhideToAlbum": "Rodyti į albumą",
|
||||
"restoreToAlbum": "Atkurti į albumą",
|
||||
"moveItem": "{count, plural, =1 {Perkelti elementą} other {Perkelti elementų}}",
|
||||
"@moveItem": {
|
||||
"description": "Page title while moving one or more items to an album"
|
||||
},
|
||||
"addItem": "{count, plural, =1 {Pridėti elementą} other {Pridėti elementų}}",
|
||||
"addItem": "{count, plural, =1 {Pridėti elementą} other {Pridėti elementų}}",
|
||||
"@addItem": {
|
||||
"description": "Page title while adding one or more items to album"
|
||||
},
|
||||
@@ -900,7 +896,7 @@
|
||||
"unlock": "Atrakinti",
|
||||
"freeUpSpace": "Atlaisvinti vietos",
|
||||
"freeUpSpaceSaving": "{count, plural, =1 {Jį galima ištrinti iš įrenginio, kad atlaisvintų {formattedSize}} other {Jų galima ištrinti iš įrenginio, kad atlaisvintų {formattedSize}}}",
|
||||
"filesBackedUpInAlbum": "{count, plural, one {{formattedNumber} failas šiame albume saugiai sukurta atsarginė kopija} few {{formattedNumber} failai šiame albume saugiai sukurtos atsarginės kopijos} many {{formattedNumber} failo šiame albume saugiai sukurtos atsargines kopijos} other {{formattedNumber} failų šiame albume saugiai sukurta atsarginė kopija}}.",
|
||||
"filesBackedUpInAlbum": "{count, plural, one {{formattedNumber} failas šiame albume saugiai sukurta atsarginė kopija} other {{formattedNumber} failų šiame albume saugiai sukurta atsarginė kopija}}.",
|
||||
"@filesBackedUpInAlbum": {
|
||||
"description": "Text to tell user how many files have been backed up in the album",
|
||||
"placeholders": {
|
||||
@@ -915,7 +911,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"filesBackedUpFromDevice": "{count, plural, one {{formattedNumber} failas šiame įrenginyje saugiai sukurta atsarginė kopija} few {{formattedNumber} failai šiame įrenginyje saugiai sukurtos atsarginės kopijos} many {{formattedNumber} failo šiame įrenginyje saugiai sukurtos atsargines kopijos} other {{formattedNumber} failų šiame įrenginyje saugiai sukurta atsarginių kopijų}}.",
|
||||
"filesBackedUpFromDevice": "{count, plural, one {{formattedNumber} failas šiame įrenginyje saugiai sukurta atsarginė kopija} other {{formattedNumber} failų šiame įrenginyje saugiai sukurta atsarginių kopijų}}.",
|
||||
"@filesBackedUpFromDevice": {
|
||||
"description": "Text to tell user how many files have been backed up from this device",
|
||||
"placeholders": {
|
||||
@@ -1403,7 +1399,7 @@
|
||||
"enableMachineLearningBanner": "Įjunkite mašininį mokymąsi magiškai paieškai ir veidų atpažinimui",
|
||||
"searchDiscoverEmptySection": "Vaizdai bus rodomi čia, kai bus užbaigtas apdorojimas ir sinchronizavimas.",
|
||||
"searchPersonsEmptySection": "Asmenys bus rodomi čia, kai bus užbaigtas apdorojimas ir sinchronizavimas.",
|
||||
"viewersSuccessfullyAdded": "{count, plural, one {Įtrauktas {count} žiūrėtojas} few {Įtraukti {count} žiūrėtojai} many {Įtraukta {count} žiūrėtojo} =0 {Įtraukta 0 žiūrėtojų} =1 {Įtrauktas 1 žiūrėtojas} other {Įtraukta {count} žiūrėtojų}}",
|
||||
"viewersSuccessfullyAdded": "{count, plural, =0 {Įtraukta 0 žiūrėtojų} =1 {Įtrauktas 1 žiūrėtojas} other {Įtraukta {count} žiūrėtojų}}",
|
||||
"@viewersSuccessfullyAdded": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
@@ -1488,7 +1484,7 @@
|
||||
},
|
||||
"currentlyRunning": "šiuo metu vykdoma",
|
||||
"ignored": "ignoruota",
|
||||
"photosCount": "{count, plural, one {{count} nuotrauka} few {{count} nuotraukos} many {{count} nuotraukos} =0 {0 nuotraukų} =1 {1 nuotrauka} other {{count} nuotraukų}}",
|
||||
"photosCount": "{count, plural, =0 {0 nuotraukų} =1 {1 nuotrauka} other {{count} nuotraukų}}",
|
||||
"@photosCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
@@ -1686,21 +1682,11 @@
|
||||
"moveSelectedPhotosToOneDate": "Perkelti pasirinktas nuotraukas į vieną datą",
|
||||
"shiftDatesAndTime": "Pastumti datas ir laiką",
|
||||
"photosKeepRelativeTimeDifference": "Nuotraukos išlaiko santykinį laiko skirtumą",
|
||||
"photocountPhotos": "{count, plural, =0 {Nėra nuotraukų} =1 {1 nuotrauka} other {{count} nuotraukų}}",
|
||||
"@photocountPhotos": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int",
|
||||
"example": "2"
|
||||
}
|
||||
}
|
||||
},
|
||||
"appIcon": "Programos piktograma",
|
||||
"notThisPerson": "Ne šis asmuo?",
|
||||
"selectedItemsWillBeRemovedFromThisPerson": "Pasirinkti elementai bus pašalinti iš šio asmens, bet nebus ištrinti iš jūsų bibliotekos.",
|
||||
"throughTheYears": "{dateFormat} per metus",
|
||||
"thisWeekThroughTheYears": "Ši savaitė per metus",
|
||||
"thisWeekXYearsAgo": "{count, plural, =1 {Šią savaitę, prieš {count} metus} other {Šią savaitę, prieš {count} metų}}",
|
||||
"youAndThem": "Jūs ir {name}",
|
||||
"admiringThem": "Žavisi {name}",
|
||||
"embracingThem": "Apkabinat {name}",
|
||||
@@ -1776,6 +1762,14 @@
|
||||
"same": "Tas pats",
|
||||
"different": "Skirtingas",
|
||||
"sameperson": "Tas pats asmuo?",
|
||||
"cLTitle1": "Pažangi vaizdų rengyklė",
|
||||
"cLDesc1": "Mes išleidžiame naują ir pažangią vaizdų rengyklę, kurioje yra daugiau apkirpimo rėmelių, filtro nustatymų sparčiams redagavimams, tikslaus sureguliavimo parinkčių, įskaitant sodrumą, kontrastą, skaistį, temperatūrą ir daug daugiau. Naujoji rengyklė taip pat suteikia galimybę piešti ant nuotraukų ir pridėti jaustukus kaip lipdukus.",
|
||||
"cLTitle2": "Išmanieji albumai",
|
||||
"cLDesc2": "Dabar galite automatiškai įtraukti pasirinktų asmenų nuotraukas į bet kurį albumą. Tiesiog eikite į albumą ir iš išskleidžiamojo meniu pasirinkite „Automatiškai įtraukti asmenis“. Jei naudojama kartu su bendrinimu albumu, nuotraukas galite bendrinti be jokių paspaudimų.",
|
||||
"cLTitle3": "Patobulinta galerija",
|
||||
"cLDesc3": "Pridėjome galimybę sugrupuoti galeriją pagal savaites, mėnesius ir metus. Dabar galite pritaikyti galeriją taip, kad ji atrodytų būtent taip, kaip norite su šiomis naujomis grupavimo parinktimis ir pasirinktiniais tinkleliais.",
|
||||
"cLTitle4": "Spartesnis slinkimas",
|
||||
"cLDesc4": "Kartu su daugybe vidinių patobulinimų pagerinti galerijos slinkimo patirtį, mes taip pat pertvarkėme slinkties juostą, kad joje būtų rodomi žymekliai, leidžiantys sparčiai pereiti per laiko juostą.",
|
||||
"indexingPausedStatusDescription": "Indeksavimas pristabdytas. Jis bus automatiškai tęsiamas, kai įrenginys bus parengtas. Įrenginys laikomas parengtu, kai jo akumuliatoriaus įkrovos lygis, akumuliatoriaus būklė ir terminė būklė yra normos ribose.",
|
||||
"thisWeek": "Šią savaitę",
|
||||
"lastWeek": "Praėjusią savaitę",
|
||||
@@ -1811,55 +1805,15 @@
|
||||
"font": "Šriftas",
|
||||
"background": "Fonas",
|
||||
"align": "Lygiuoti",
|
||||
"videosProcessed": "Vaizdo įrašai apdoroti",
|
||||
"totalVideos": "Iš viso vaizdo įrašų",
|
||||
"skippedVideos": "Praleisti vaizdo įrašai",
|
||||
"similarImages": "Panašūs vaizdai",
|
||||
"findSimilarImages": "Rasti panašų vaizdų",
|
||||
"noSimilarImagesFound": "Panašių vaizdų nerasta",
|
||||
"yourPhotosLookUnique": "Jūsų nuotraukos atrodo ypatingos",
|
||||
"selectionOptions": "Pasirinkimo parinktys",
|
||||
"selectAllWithCount": "Visi panašumai ({count})",
|
||||
"@selectAllWithCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"chooseSimilarImagesToSelect": "Pasirinkite vaizdus pagal jų vizualinį panašumą.",
|
||||
"deleteFiles": "Ištrinti failus",
|
||||
"areYouSureDeleteFiles": "Ar tikrai norite ištrinti šiuos failus?",
|
||||
"greatJob": "Puiku!",
|
||||
"cleanedUpSimilarImages": "Atlaisvinote {size} vietos.",
|
||||
"@cleanedUpSimilarImages": {
|
||||
"placeholders": {
|
||||
"size": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"size": "Dydis",
|
||||
"similarity": "Panašumas",
|
||||
"analyzingPhotosLocally": "Analizuojamos jūsų nuotraukos vietoje...",
|
||||
"lookingForVisualSimilarities": "Ieškoma vizualių panašumų...",
|
||||
"comparingImageDetails": "Lyginama vaizdo išsami informacija...",
|
||||
"findingSimilarImages": "Randama panašių vaizdų...",
|
||||
"almostDone": "Beveik baigta...",
|
||||
"processingLocally": "Apdorojama vietoje",
|
||||
"useMLToFindSimilarImages": "Peržiūrėkite ir pašalinkite vaizdus, kurie atrodo panašūs vienas į kitą.",
|
||||
"all": "Visi",
|
||||
"similar": "Panašūs",
|
||||
"identical": "Vienodi",
|
||||
"nothingHereTryAnotherFilter": "Čia nieko nėra. Bandykite kitą filtrą. 👀",
|
||||
"related": "Susiję",
|
||||
"hoorayyyy": "Puiiiiku!",
|
||||
"nothingToTidyUpHere": "Čia nėra ko tvarkyti.",
|
||||
"deletingDash": "Ištrinama - ",
|
||||
"cLTitle1": "Panašūs vaizdai",
|
||||
"cLDesc1": "Pristatome naują MM sistemą, skirtą aptikti panašius vaizdus, kurią naudojant galite išvalyti savo biblioteką. Pasiekiama Nustatymuose -> Atsarginė kopija -> Atlaisvinti vietą.",
|
||||
"cLTitle2": "Vaizdo įrašų srautinio perdavimo patobulinimai",
|
||||
"cLDesc2": "Dabar galite rankiniu būdu paleisti vaizdo įrašų srauto perdavimo generavimą tiesiai iš programos. Taip pat pridėjome naują vaizdo įrašų srauto perdavimo nustatymų ekraną, kuriame bus rodomas procentinis vaizdo įrašų skaičius, apdorotų srauto perdavimui.",
|
||||
"cLTitle3": "Našumo patobulinimai",
|
||||
"cLDesc3": "Daugybė patobulinimų, įskaitant geresnį podėlio naudojimą ir sklandesnį slinkimo patirtį."
|
||||
}
|
||||
"processingLocally": "Apdorojama vietoje"
|
||||
}
|
||||
|
||||
@@ -477,7 +477,7 @@
|
||||
}
|
||||
},
|
||||
"showMemories": "Toon herinneringen",
|
||||
"yearsAgo": "{count, plural, one{{count} jaar geleden} other{{count} jaar geleden}}",
|
||||
"yearsAgo": "{count, plural, other{{count} jaar geleden}}",
|
||||
"backupSettings": "Back-up instellingen",
|
||||
"backupStatus": "Back-up status",
|
||||
"backupStatusDescription": "Items die zijn geback-upt, worden hier getoond",
|
||||
@@ -1773,4 +1773,4 @@
|
||||
"areYouSureYouWantToMergeThem": "Weet je zeker dat je ze wilt samenvoegen?",
|
||||
"allUnnamedGroupsWillBeMergedIntoTheSelectedPerson": "Alle naamloze groepen worden samengevoegd met de geselecteerde persoon. Dit kan nog steeds ongedaan worden gemaakt vanuit het geschiedenisoverzicht van de persoon.",
|
||||
"yesIgnore": "Ja, negeer"
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user