Compare commits
235 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5e681f53e | ||
|
|
2fd1e58b19 | ||
|
|
a5965bcaec | ||
|
|
4f5af8dcfa | ||
|
|
8079d44c68 | ||
|
|
cd5582219c | ||
|
|
69332c78ad | ||
|
|
cba30e386d | ||
|
|
7663e76deb | ||
|
|
697d6f854d | ||
|
|
7aadb54ef1 | ||
|
|
2a2443efea | ||
|
|
d2bc2627a3 | ||
|
|
b1971810fb | ||
|
|
bd25af2b4b | ||
|
|
833b4656fe | ||
|
|
315c4ae6b7 | ||
|
|
49d9b3c928 | ||
|
|
ba6b326f97 | ||
|
|
aeea35e32a | ||
|
|
614c6c63aa | ||
|
|
ba6cee23d9 | ||
|
|
e43266c176 | ||
|
|
f4168cb9a3 | ||
|
|
1e551b4084 | ||
|
|
df6392fd19 | ||
|
|
e4a851072d | ||
|
|
f9c4442223 | ||
|
|
c4e7139ecb | ||
|
|
ddd4b733d3 | ||
|
|
3836cac109 | ||
|
|
06eda153be | ||
|
|
6137d07ba8 | ||
|
|
0f92b098b7 | ||
|
|
7bde215427 | ||
|
|
4953310876 | ||
|
|
2932ee7d4c | ||
|
|
0e0ba2d5af | ||
|
|
3b54fa41f6 | ||
|
|
c51dff5a29 | ||
|
|
e985200e67 | ||
|
|
7e5e11ba87 | ||
|
|
13c9646f58 | ||
|
|
678b556f5f | ||
|
|
a3b432799a | ||
|
|
8eaa2603dd | ||
|
|
b51febf8f5 | ||
|
|
df522658bb | ||
|
|
9a13b99b20 | ||
|
|
a142b660fd | ||
|
|
b7dcb7b34c | ||
|
|
e8de5940fd | ||
|
|
d5f8c9eb24 | ||
|
|
f092396133 | ||
|
|
7f718438aa | ||
|
|
cf7a4d989d | ||
|
|
e444c1801a | ||
|
|
f2a2ee188c | ||
|
|
356622cbb1 | ||
|
|
86c92a9217 | ||
|
|
bcc2a30105 | ||
|
|
dcc36d2d35 | ||
|
|
d650886749 | ||
|
|
a73d5548a0 | ||
|
|
bf0b11ebfd | ||
|
|
49c90a802a | ||
|
|
8b2db5e576 | ||
|
|
57382af3a2 | ||
|
|
80bc848d1e | ||
|
|
b11f86175e | ||
|
|
b5d4839e04 | ||
|
|
ac57097eb4 | ||
|
|
4e08e38bf6 | ||
|
|
a7d3cf4178 | ||
|
|
c63dfc36e9 | ||
|
|
2985503254 | ||
|
|
9be023d68a | ||
|
|
6a6e1b3c47 | ||
|
|
7516363715 | ||
|
|
2b76b71db8 | ||
|
|
c32a70fb25 | ||
|
|
4098c1a072 | ||
|
|
972be1f41e | ||
|
|
2e58400962 | ||
|
|
b0fce602aa | ||
|
|
3acb2136d0 | ||
|
|
eba729625f | ||
|
|
a477742cd0 | ||
|
|
c974bde11c | ||
|
|
ecc654bae0 | ||
|
|
201ef88305 | ||
|
|
742035d7cc | ||
|
|
8f29d5aa19 | ||
|
|
8a4e76fb6f | ||
|
|
c03eaf83aa | ||
|
|
378878538d | ||
|
|
01c3d6b105 | ||
|
|
2bdf62c490 | ||
|
|
c6f5c68f1e | ||
|
|
d0c8925ff3 | ||
|
|
2cab943647 | ||
|
|
d6c84421ce | ||
|
|
990485d796 | ||
|
|
96e9030d40 | ||
|
|
0d1f20f9e2 | ||
|
|
c55447a08f | ||
|
|
98d56e8fa4 | ||
|
|
f244c94ebf | ||
|
|
88f2b88f4d | ||
|
|
db1fef40db | ||
|
|
1fd29cdd13 | ||
|
|
947d294afe | ||
|
|
515715660e | ||
|
|
324221171d | ||
|
|
f5f2ff1b2c | ||
|
|
244d41621c | ||
|
|
91b6a08a35 | ||
|
|
770a311da5 | ||
|
|
db76dee639 | ||
|
|
20ce760e85 | ||
|
|
df1bfbe839 | ||
|
|
27d72eb821 | ||
|
|
98786c5824 | ||
|
|
d38a09c3f0 | ||
|
|
b1f28e3f2e | ||
|
|
c155bdd058 | ||
|
|
a859f28e2c | ||
|
|
8d75528aa5 | ||
|
|
7f43c11985 | ||
|
|
aadda7e3f6 | ||
|
|
bee2bb9621 | ||
|
|
3c49ca0f6e | ||
|
|
233f03355f | ||
|
|
2257087bb2 | ||
|
|
2a5bce2ae4 | ||
|
|
1e0a6eb1ea | ||
|
|
187a729013 | ||
|
|
c98f4dfffd | ||
|
|
4140a0f6fe | ||
|
|
a9d5773b9a | ||
|
|
ac68b99ecf | ||
|
|
82e1a0e358 | ||
|
|
034e789242 | ||
|
|
ccfec4071f | ||
|
|
c4830732fd | ||
|
|
72dc56e41f | ||
|
|
8dd3ad9f5b | ||
|
|
2ebb920faa | ||
|
|
e9f55b968a | ||
|
|
5036a8da59 | ||
|
|
6775faf0d0 | ||
|
|
367dc18caa | ||
|
|
0c6db4661e | ||
|
|
b6489f4c41 | ||
|
|
e7d7f1cdd0 | ||
|
|
bbbdd96c9e | ||
|
|
3c23d3b480 | ||
|
|
3805cddeba | ||
|
|
824c324342 | ||
|
|
0f5e30e96b | ||
|
|
35ded7bc59 | ||
|
|
8e3f6e56d2 | ||
|
|
6ded21fe87 | ||
|
|
be4b521879 | ||
|
|
326eb3ff8a | ||
|
|
adef8bd466 | ||
|
|
a1d9fb5969 | ||
|
|
6da615b7dc | ||
|
|
41a268b1cb | ||
|
|
ed07e64fa5 | ||
|
|
150534aa1a | ||
|
|
bdfe363066 | ||
|
|
2a136ba087 | ||
|
|
3abb479fbf | ||
|
|
7eda60a493 | ||
|
|
bb8c5caa8d | ||
|
|
0384819c01 | ||
|
|
f55973367d | ||
|
|
699794226f | ||
|
|
dee68acfc3 | ||
|
|
0bd5452837 | ||
|
|
e53ddb8b51 | ||
|
|
95d167878e | ||
|
|
653fc47aed | ||
|
|
34325691e7 | ||
|
|
e474114e22 | ||
|
|
80c07d36a9 | ||
|
|
8581742a73 | ||
|
|
042dae8790 | ||
|
|
45249e0cdf | ||
|
|
ebfcedac7b | ||
|
|
2900ca55f5 | ||
|
|
2a40aa472e | ||
|
|
62cb67f3bf | ||
|
|
e393b92a3d | ||
|
|
e06d65e8a0 | ||
|
|
a4ec8c939a | ||
|
|
b8dd379306 | ||
|
|
42229bd331 | ||
|
|
ad9a3977a3 | ||
|
|
afb93df48f | ||
|
|
4ce38ecea0 | ||
|
|
4c63c8fc25 | ||
|
|
158b48e4dc | ||
|
|
84f5a5ac3d | ||
|
|
a00fc0b1be | ||
|
|
f5347e7436 | ||
|
|
3f1d574d0c | ||
|
|
891b68c0f4 | ||
|
|
f050c6f9d7 | ||
|
|
2de67b619f | ||
|
|
828dde5ca7 | ||
|
|
2526c69896 | ||
|
|
6e64a2067f | ||
|
|
ab4792518f | ||
|
|
d4ae8d63fc | ||
|
|
618753cb1a | ||
|
|
f84bd20bbf | ||
|
|
6ae7aa70d6 | ||
|
|
48757af5d0 | ||
|
|
cd20a98850 | ||
|
|
9ac9e6bd26 | ||
|
|
0b640c9062 | ||
|
|
2d87aba165 | ||
|
|
7dffdfaecf | ||
|
|
a4da7b5555 | ||
|
|
85b766b5d0 | ||
|
|
62f715d3c1 | ||
|
|
e35ae86fa5 | ||
|
|
ea843eba7a | ||
|
|
b845f4d893 | ||
|
|
8ea36acb7a | ||
|
|
279df8ff57 | ||
|
|
d83994c692 | ||
|
|
be506bdad1 |
@@ -48,7 +48,11 @@ 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.
|
||||
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.
|
||||
|
||||
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 (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.
|
||||
> 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).
|
||||
|
||||
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 (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.
|
||||
> 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).
|
||||
|
||||
## Unsetting
|
||||
|
||||
@@ -103,3 +103,7 @@ 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 across albums.
|
||||
that can be manually run afterwards to remove duplicates and very similar files 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 different on each platform, to cater to
|
||||
The duplicate detection works slightly differently 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 also provides a tool for manual de-duplication in _Settings → Backup →
|
||||
Ente provides a tool for manual de-duplication in _Settings → Backup → Free up space →
|
||||
Remove duplicates_. This is useful if you have an existing library with
|
||||
duplicates across different albums, but wish to keep only one copy.
|
||||
|
||||
@@ -57,6 +57,13 @@ 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,7 +382,8 @@ class _HomePageState extends State<HomePage> {
|
||||
final bool shouldShowLockScreen =
|
||||
await LockScreenSettings.instance.shouldShowLockScreen();
|
||||
if (shouldShowLockScreen) {
|
||||
await AppLock.of(context)!.showLockScreen();
|
||||
// Manual lock: do not auto-prompt Touch ID; wait for user tap
|
||||
await AppLock.of(context)!.showManualLockScreen();
|
||||
} else {
|
||||
await showDialogWidget(
|
||||
context: context,
|
||||
|
||||
@@ -54,4 +54,5 @@
|
||||
<data android:mimeType="text/plain"/>
|
||||
</intent>
|
||||
</queries>
|
||||
<uses-permission android:name="android.permission.USE_BIOMETRIC"/>
|
||||
</manifest>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package io.ente.locker
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
import io.flutter.embedding.android.FlutterFragmentActivity
|
||||
|
||||
class MainActivity: FlutterActivity()
|
||||
class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
|
||||
BIN
mobile/apps/locker/assets/2.0x/loading_photos_background.png
Normal file
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 39 KiB |
BIN
mobile/apps/locker/assets/3.0x/loading_photos_background.png
Normal file
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 63 KiB |
20
mobile/apps/locker/assets/icons/legacy-dark.svg
Normal file
@@ -0,0 +1,20 @@
|
||||
<svg width="200" height="200" viewBox="0 0 200 200" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_34053_111310)">
|
||||
<path d="M170 66.7593C170 71.8084 169.042 76.732 167.151 81.3933C165.192 86.2233 162.316 90.5536 158.606 94.265L147.36 105.511L151.657 109.806C152.962 111.112 152.26 113.347 150.441 113.671L128.349 117.598C126.785 117.875 125.423 116.513 125.7 114.949L129.628 92.8595C129.952 91.0413 132.187 90.3393 133.492 91.6442L137.12 95.2717L148.366 84.0261C152.978 79.4144 155.519 73.282 155.519 66.7609C155.519 60.2383 152.978 54.105 148.365 49.4941C143.753 44.8815 137.621 42.3422 131.097 42.3422C124.574 42.3422 118.442 44.8815 113.83 49.4941L107.387 55.936C107.383 55.9064 107.378 55.8784 107.373 55.8488L104.299 38.5628C107.848 35.1847 111.936 32.5447 116.463 30.7097C121.123 28.818 126.048 27.8594 131.097 27.8594C136.146 27.8594 141.07 28.818 145.732 30.7089C150.564 32.667 154.894 35.5421 158.606 39.2528C162.317 42.9643 165.192 47.2946 167.151 52.1246C169.041 56.7859 169.999 61.711 170 66.7593Z" fill="#4AAF3C"/>
|
||||
<path d="M133.814 119.087L105.12 147.779C103.762 149.137 101.92 149.9 99.9998 149.9C98.0791 149.9 96.2375 149.137 94.879 147.779L70.7775 123.678L67.503 126.953C66.1972 128.259 63.9623 127.556 63.6392 125.737L59.7107 103.646C59.4332 102.083 60.7966 100.72 62.3598 100.997L84.4519 104.925C86.2702 105.249 86.9731 107.483 85.6673 108.789L81.0183 113.438L100.001 132.42L124.507 107.914L123.318 114.601C123.053 116.096 123.535 117.63 124.609 118.703C125.684 119.778 127.217 120.259 128.712 119.994L133.814 119.087H133.814Z" fill="#4AAF3C"/>
|
||||
<path d="M102.359 58.8607L80.2668 54.9325C78.4485 54.6095 77.7456 52.3748 79.0514 51.0692L83.1799 46.9412C79.0498 43.9533 74.1009 42.3406 68.9034 42.3406C62.3808 42.3406 56.2477 44.88 51.6363 49.4925C47.0232 54.1042 44.4828 60.2359 44.4828 66.7585C44.4828 73.2812 47.0232 79.4128 51.6363 84.0246L66.9435 99.3301L62.7415 98.5834C61.2462 98.3179 59.7125 98.8 58.6386 99.8738C57.5647 100.948 57.0825 102.482 57.348 103.977L58.697 111.564L41.3955 94.2635C37.6836 90.552 34.8089 86.2217 32.8499 81.3917C30.9588 76.7312 30 71.8076 30 66.7593C30 61.711 30.9588 56.7867 32.8491 52.1254C34.8081 47.2946 37.6836 42.9643 41.3947 39.2536C45.1057 35.5421 49.4365 32.6678 54.267 30.7097C58.9296 28.818 63.8537 27.8594 68.9034 27.8594C73.953 27.8594 78.8771 28.818 83.5389 30.7081C87.158 32.1753 90.4956 34.1565 93.503 36.6183L97.2157 32.9061C98.5215 31.6004 100.756 32.3032 101.079 34.1214L105.007 56.211C105.286 57.7741 103.922 59.1381 102.359 58.8607Z" fill="#4AAF3C"/>
|
||||
</g>
|
||||
<g filter="url(#filter0_f_34053_111310)">
|
||||
<ellipse cx="102.576" cy="170.536" rx="27.5758" ry="1.59085" fill="#1CA609" fill-opacity="0.5"/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_f_34053_111310" x="70.5" y="164.445" width="64.1515" height="12.1797" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="2.25" result="effect1_foregroundBlur_34053_111310"/>
|
||||
</filter>
|
||||
<clipPath id="clip0_34053_111310">
|
||||
<rect width="140" height="122" fill="white" transform="translate(30 27.8828)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.2 KiB |
20
mobile/apps/locker/assets/icons/legacy-light.svg
Normal file
@@ -0,0 +1,20 @@
|
||||
<svg width="200" height="200" viewBox="0 0 200 200" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_34053_111286)">
|
||||
<path d="M170 66.7593C170 71.8084 169.042 76.732 167.151 81.3933C165.192 86.2233 162.316 90.5536 158.606 94.265L147.36 105.511L151.657 109.806C152.962 111.112 152.26 113.347 150.441 113.671L128.349 117.598C126.785 117.875 125.423 116.513 125.7 114.949L129.628 92.8594C129.952 91.0413 132.187 90.3393 133.492 91.6441L137.12 95.2717L148.366 84.0261C152.978 79.4144 155.519 73.2819 155.519 66.7609C155.519 60.2383 152.978 54.105 148.365 49.4941C143.753 44.8815 137.621 42.3422 131.097 42.3422C124.574 42.3422 118.442 44.8815 113.83 49.4941L107.387 55.936C107.383 55.9064 107.378 55.8784 107.373 55.8488L104.299 38.5628C107.848 35.1847 111.936 32.5447 116.463 30.7097C121.123 28.818 126.048 27.8594 131.097 27.8594C136.146 27.8594 141.07 28.818 145.732 30.7089C150.564 32.667 154.894 35.5421 158.606 39.2528C162.317 42.9643 165.192 47.2946 167.151 52.1246C169.041 56.7859 169.999 61.7102 170 66.7585V66.7593Z" fill="#4AAF3C"/>
|
||||
<path d="M133.814 119.087L105.12 147.779C103.762 149.137 101.92 149.9 99.9998 149.9C98.0791 149.9 96.2375 149.137 94.879 147.779L70.7775 123.678L67.503 126.953C66.1972 128.259 63.9623 127.556 63.6392 125.737L59.7107 103.646C59.4332 102.083 60.7966 100.72 62.3598 100.997L84.4519 104.925C86.2702 105.249 86.9731 107.483 85.6673 108.789L81.0183 113.438L100.001 132.42L124.507 107.914L123.318 114.601C123.053 116.096 123.535 117.63 124.609 118.703C125.684 119.778 127.217 120.259 128.712 119.994L133.814 119.087H133.814Z" fill="#4AAF3C"/>
|
||||
<path d="M102.359 58.8607L80.2668 54.9325C78.4485 54.6095 77.7456 52.3748 79.0514 51.0692L83.1799 46.9412C79.0498 43.9533 74.1009 42.3406 68.9034 42.3406C62.3808 42.3406 56.2477 44.88 51.6363 49.4925C47.0232 54.1042 44.4828 60.2359 44.4828 66.7585C44.4828 73.2812 47.0232 79.4128 51.6363 84.0246L66.9435 99.3301L62.7415 98.5834C61.2462 98.3179 59.7125 98.8 58.6386 99.8738C57.5647 100.948 57.0825 102.482 57.348 103.977L58.697 111.564L41.3955 94.2635C37.6836 90.552 34.8089 86.2217 32.8499 81.3917C30.9588 76.7312 30 71.8076 30 66.7593C30 61.711 30.9588 56.7867 32.8491 52.1254C34.8081 47.2946 37.6836 42.9643 41.3947 39.2536C45.1057 35.5421 49.4365 32.6678 54.267 30.7097C58.9296 28.818 63.8537 27.8594 68.9034 27.8594C73.953 27.8594 78.8771 28.818 83.5389 30.7081C87.158 32.1753 90.4957 34.1565 93.503 36.6183L97.2157 32.9061C98.5215 31.6004 100.756 32.3032 101.079 34.1214L105.007 56.211C105.286 57.7741 103.922 59.1373 102.359 58.8599V58.8607Z" fill="#4AAF3C"/>
|
||||
</g>
|
||||
<g filter="url(#filter0_f_34053_111286)">
|
||||
<ellipse cx="101.576" cy="170.536" rx="27.5758" ry="1.59085" fill="#E1E1E1" fill-opacity="0.5"/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_f_34053_111286" x="69.5" y="164.445" width="64.1515" height="12.1797" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="2.25" result="effect1_foregroundBlur_34053_111286"/>
|
||||
</filter>
|
||||
<clipPath id="clip0_34053_111286">
|
||||
<rect width="140" height="122" fill="white" transform="translate(30 27.8828)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.2 KiB |
BIN
mobile/apps/locker/assets/loading_photos_background.png
Normal file
|
After Width: | Height: | Size: 68 KiB |
BIN
mobile/apps/locker/assets/loading_photos_background_dark.png
Normal file
|
After Width: | Height: | Size: 63 KiB |
@@ -188,37 +188,37 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/url_launcher_ios/ios"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
app_links: f3e17e4ee5e357b39d8b95290a9b2c299fca71c6
|
||||
cupertino_http: 947a233f40cfea55167a49f2facc18434ea117ba
|
||||
device_info_plus: c6fb39579d0f423935b0c9ce7ee2f44b71b9fce6
|
||||
app_links: 76b66b60cc809390ca1ad69bfd66b998d2387ac7
|
||||
cupertino_http: 94ac07f5ff090b8effa6c5e2c47871d48ab7c86c
|
||||
device_info_plus: 335f3ce08d2e174b9fdc3db3db0f4e3b1f66bd89
|
||||
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
|
||||
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
|
||||
file_picker: b159e0c068aef54932bb15dc9fd1571818edaf49
|
||||
file_saver: 503e386464dbe118f630e17b4c2e1190fa0cf808
|
||||
file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be
|
||||
file_saver: 6cdbcddd690cb02b0c1a0c225b37cd805c2bf8b6
|
||||
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
|
||||
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
|
||||
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
|
||||
OrderedSet: e539b66b644ff081c73a262d24ad552a69be3a94
|
||||
package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4
|
||||
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
|
||||
privacy_screen: 1a131c052ceb3c3659934b003b0d397c2381a24e
|
||||
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
|
||||
path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564
|
||||
privacy_screen: 3159a541f5d3a31bea916cfd4e58f9dc722b3fd4
|
||||
SDWebImage: f29024626962457f3470184232766516dee8dfea
|
||||
Sentry: da60d980b197a46db0b35ea12cb8f39af48d8854
|
||||
sentry_flutter: 2df8b0aab7e4aba81261c230cbea31c82a62dd1b
|
||||
share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f
|
||||
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
|
||||
sodium_libs: 1faae17af662384acbd13e41867a0008cd2e2318
|
||||
sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
|
||||
sentry_flutter: 27892878729f42701297c628eb90e7c6529f3684
|
||||
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
|
||||
shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7
|
||||
sodium_libs: 6c6d0e83f4ee427c6464caa1f1bdc2abf3ca0b7f
|
||||
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
|
||||
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
|
||||
ua_client_hints: aeabd123262c087f0ce151ef96fa3ab77bfc8b38
|
||||
url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe
|
||||
ua_client_hints: 92fe0d139619b73ec9fcb46cc7e079a26178f586
|
||||
url_launcher_ios: 694010445543906933d732453a59da0a173ae33d
|
||||
|
||||
PODFILE CHECKSUM: d2d3220ea22664a259778d9e314054751db31361
|
||||
|
||||
|
||||
@@ -18,6 +18,9 @@ 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,6 +17,8 @@ class WiFiUnavailableError extends Error {}
|
||||
|
||||
class SilentlyCancelUploadsError extends Error {}
|
||||
|
||||
class SharingNotPermittedForFreeAccountsError extends Error {}
|
||||
|
||||
class InvalidFileError extends ArgumentError {
|
||||
final InvalidReason reason;
|
||||
|
||||
|
||||
12
mobile/apps/locker/lib/extensions/user_extension.dart
Normal file
@@ -0,0 +1,12 @@
|
||||
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,5 +349,162 @@
|
||||
"mastodon": "Mastodon",
|
||||
"matrix": "Matrix",
|
||||
"discord": "Discord",
|
||||
"reddit": "Reddit"
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -1017,6 +1017,588 @@ 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,4 +534,380 @@ 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,6 +4,7 @@ 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';
|
||||
@@ -169,4 +170,8 @@ Future<void> _init(bool bool, {String? via}) async {
|
||||
packageInfo,
|
||||
);
|
||||
await TrashService.instance.init(preferences);
|
||||
await EmergencyContactService.instance.init(
|
||||
UserService.instance,
|
||||
Configuration.instance,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
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';
|
||||
@@ -29,7 +36,11 @@ class CollectionApiClient {
|
||||
final _enteDio = Network.instance.enteDio;
|
||||
final _config = Configuration.instance;
|
||||
|
||||
Future<void> init() async {}
|
||||
late CollectionDB _db;
|
||||
|
||||
Future<void> init() async {
|
||||
_db = CollectionDB.instance;
|
||||
}
|
||||
|
||||
Future<List<Collection>> getCollections(int sinceTime) async {
|
||||
try {
|
||||
@@ -161,6 +172,18 @@ 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,
|
||||
@@ -394,6 +417,86 @@ 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,10 +4,15 @@ 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';
|
||||
@@ -16,8 +21,6 @@ import "package:locker/utils/crypto_helper.dart";
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
class CollectionService {
|
||||
CollectionService._privateConstructor();
|
||||
|
||||
static final CollectionService instance =
|
||||
CollectionService._privateConstructor();
|
||||
|
||||
@@ -36,7 +39,16 @@ class CollectionService {
|
||||
};
|
||||
|
||||
final _logger = Logger("CollectionService");
|
||||
final _apiClient = CollectionApiClient.instance;
|
||||
|
||||
late CollectionApiClient _apiClient;
|
||||
late CollectionDB _db;
|
||||
|
||||
final _collectionIDToCollections = <int, Collection>{};
|
||||
|
||||
CollectionService._privateConstructor() {
|
||||
_db = CollectionDB.instance;
|
||||
_apiClient = CollectionApiClient.instance;
|
||||
}
|
||||
|
||||
Future<void> init() async {
|
||||
if (Configuration.instance.hasConfiguredAccount()) {
|
||||
@@ -50,41 +62,45 @@ class CollectionService {
|
||||
}
|
||||
|
||||
Future<void> sync() async {
|
||||
final updatedCollections = await CollectionApiClient.instance
|
||||
.getCollections(CollectionDB.instance.getSyncTime());
|
||||
final updatedCollections =
|
||||
await CollectionApiClient.instance.getCollections(_db.getSyncTime());
|
||||
if (updatedCollections.isEmpty) {
|
||||
_logger.info("No collections to sync.");
|
||||
return;
|
||||
}
|
||||
await CollectionDB.instance.updateCollections(updatedCollections);
|
||||
await CollectionDB.instance
|
||||
.setSyncTime(updatedCollections.last.updationTime);
|
||||
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);
|
||||
|
||||
final List<Future> fileFutures = [];
|
||||
for (final collection in updatedCollections) {
|
||||
if (collection.isDeleted) {
|
||||
await CollectionDB.instance.deleteCollection(collection);
|
||||
await _db.deleteCollection(collection);
|
||||
_collectionIDToCollections.remove(collection.id);
|
||||
continue;
|
||||
}
|
||||
final syncTime =
|
||||
CollectionDB.instance.getCollectionSyncTime(collection.id);
|
||||
final syncTime = _db.getCollectionSyncTime(collection.id);
|
||||
fileFutures.add(
|
||||
CollectionApiClient.instance
|
||||
.getFiles(collection, syncTime)
|
||||
.then((diff) async {
|
||||
_apiClient.getFiles(collection, syncTime).then((diff) async {
|
||||
if (diff.updatedFiles.isNotEmpty) {
|
||||
await CollectionDB.instance.addFilesToCollection(
|
||||
await _db.addFilesToCollection(
|
||||
collection,
|
||||
diff.updatedFiles,
|
||||
);
|
||||
}
|
||||
if (diff.deletedFiles.isNotEmpty) {
|
||||
await CollectionDB.instance.deleteFilesFromCollection(
|
||||
await _db.deleteFilesFromCollection(
|
||||
collection,
|
||||
diff.deletedFiles,
|
||||
);
|
||||
}
|
||||
await CollectionDB.instance
|
||||
.setCollectionSyncTime(collection.id, diff.latestUpdatedAtTime);
|
||||
await _db.setCollectionSyncTime(
|
||||
collection.id,
|
||||
diff.latestUpdatedAtTime,
|
||||
);
|
||||
}).catchError((e) {
|
||||
_logger.warning(
|
||||
"Failed to fetch files for collection ${collection.id}: $e",
|
||||
@@ -100,7 +116,7 @@ class CollectionService {
|
||||
|
||||
bool hasCompletedFirstSync() {
|
||||
return Configuration.instance.hasConfiguredAccount() &&
|
||||
CollectionDB.instance.getSyncTime() > 0;
|
||||
_db.getSyncTime() > 0;
|
||||
}
|
||||
|
||||
Future<Collection> createCollection(
|
||||
@@ -120,17 +136,37 @@ class CollectionService {
|
||||
}
|
||||
|
||||
Future<List<Collection>> getCollections() async {
|
||||
return CollectionDB.instance.getCollections();
|
||||
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);
|
||||
}
|
||||
|
||||
Future<List<Collection>> getCollectionsForFile(EnteFile file) async {
|
||||
return CollectionDB.instance.getCollectionsForFile(file);
|
||||
return _db.getCollectionsForFile(file);
|
||||
}
|
||||
|
||||
Future<List<EnteFile>> getFilesInCollection(Collection collection) async {
|
||||
try {
|
||||
final files =
|
||||
await CollectionDB.instance.getFilesInCollection(collection);
|
||||
final files = await _db.getFilesInCollection(collection);
|
||||
return files;
|
||||
} catch (e) {
|
||||
_logger.severe(
|
||||
@@ -142,7 +178,7 @@ class CollectionService {
|
||||
|
||||
Future<List<EnteFile>> getAllFiles() async {
|
||||
try {
|
||||
final allFiles = await CollectionDB.instance.getAllFiles();
|
||||
final allFiles = await _db.getAllFiles();
|
||||
return allFiles;
|
||||
} catch (e) {
|
||||
_logger.severe("Failed to fetch all files: $e");
|
||||
@@ -178,7 +214,7 @@ class CollectionService {
|
||||
|
||||
Future<void> rename(Collection collection, String newName) async {
|
||||
try {
|
||||
await CollectionApiClient.instance.rename(
|
||||
await _apiClient.rename(
|
||||
collection,
|
||||
newName,
|
||||
);
|
||||
@@ -212,6 +248,10 @@ 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 {
|
||||
@@ -313,12 +353,17 @@ class CollectionService {
|
||||
}
|
||||
|
||||
Future<Collection> getCollection(int collectionID) async {
|
||||
return await CollectionDB.instance.getCollection(collectionID);
|
||||
if (_collectionIDToCollections.containsKey(collectionID)) {
|
||||
return _collectionIDToCollections[collectionID]!;
|
||||
}
|
||||
final collection = await _db.getCollection(collectionID);
|
||||
_collectionIDToCollections[collectionID] = collection;
|
||||
return collection;
|
||||
}
|
||||
|
||||
Future<Uint8List> getCollectionKey(int collectionID) async {
|
||||
final collection = await getCollection(collectionID);
|
||||
final collectionKey = CryptoHelper.instance.getCollectionKey(collection);
|
||||
Uint8List getCollectionKey(int collectionID) {
|
||||
final collection = _collectionIDToCollections[collectionID];
|
||||
final collectionKey = CryptoHelper.instance.getCollectionKey(collection!);
|
||||
return collectionKey;
|
||||
}
|
||||
|
||||
@@ -340,4 +385,94 @@ 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/user.dart';
|
||||
import 'package:locker/services/collections/models/public_url.dart';
|
||||
import 'package:locker/services/files/sync/models/common_keys.dart';
|
||||
|
||||
class Collection {
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
67
mobile/apps/locker/lib/ui/collections/section_title.dart
Normal file
@@ -0,0 +1,67 @@
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
53
mobile/apps/locker/lib/ui/components/button/copy_button.dart
Normal file
@@ -0,0 +1,53 @@
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
210
mobile/apps/locker/lib/ui/components/collection_row_widget.dart
Normal file
@@ -0,0 +1,210 @@
|
||||
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());
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
572
mobile/apps/locker/lib/ui/components/file_row_widget.dart
Normal file
@@ -0,0 +1,572 @@
|
||||
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,28 +1,12 @@
|
||||
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:flutter/services.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/file_edit_dialog.dart';
|
||||
import 'package:locker/ui/pages/collection_page.dart';
|
||||
import 'package:locker/utils/collection_actions.dart';
|
||||
import "package:locker/ui/components/collection_row_widget.dart";
|
||||
import "package:locker/ui/components/file_row_widget.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;
|
||||
@@ -400,767 +384,6 @@ 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,8 +18,19 @@ 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 {
|
||||
const AllCollectionsPage({super.key});
|
||||
final UISectionType viewType;
|
||||
|
||||
const AllCollectionsPage({
|
||||
super.key,
|
||||
this.viewType = UISectionType.homeCollections,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AllCollectionsPage> createState() => _AllCollectionsPageState();
|
||||
@@ -34,6 +45,8 @@ class _AllCollectionsPageState extends State<AllCollectionsPage>
|
||||
List<EnteFile> _allFiles = [];
|
||||
bool _isLoading = true;
|
||||
String? _error;
|
||||
bool showTrash = false;
|
||||
bool showUncategorized = false;
|
||||
final _logger = Logger("AllCollectionsPage");
|
||||
|
||||
@override
|
||||
@@ -68,6 +81,10 @@ 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 {
|
||||
@@ -77,7 +94,19 @@ class _AllCollectionsPageState extends State<AllCollectionsPage>
|
||||
});
|
||||
|
||||
try {
|
||||
final collections = await CollectionService.instance.getCollections();
|
||||
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 regularCollections = <Collection>[];
|
||||
Collection? uncategorized;
|
||||
@@ -94,8 +123,12 @@ class _AllCollectionsPageState extends State<AllCollectionsPage>
|
||||
|
||||
_allCollections = List.from(collections);
|
||||
_sortedCollections = List.from(regularCollections);
|
||||
_uncategorizedCollection = uncategorized;
|
||||
_uncategorizedFileCount = uncategorized != null
|
||||
_uncategorizedCollection =
|
||||
widget.viewType == UISectionType.homeCollections
|
||||
? uncategorized
|
||||
: null;
|
||||
_uncategorizedFileCount = uncategorized != null &&
|
||||
widget.viewType == UISectionType.homeCollections
|
||||
? (await CollectionService.instance
|
||||
.getFilesInCollection(uncategorized))
|
||||
.length
|
||||
@@ -122,7 +155,7 @@ class _AllCollectionsPageState extends State<AllCollectionsPage>
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: buildSearchLeading(),
|
||||
title: Text(context.l10n.collections),
|
||||
title: Text(_getTitle(context)),
|
||||
centerTitle: false,
|
||||
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
||||
foregroundColor: Theme.of(context).textTheme.bodyLarge?.color,
|
||||
@@ -237,9 +270,11 @@ class _AllCollectionsPageState extends State<AllCollectionsPage>
|
||||
enableSorting: true,
|
||||
),
|
||||
),
|
||||
if (!isSearchActive && _uncategorizedCollection != null)
|
||||
if (!isSearchActive &&
|
||||
_uncategorizedCollection != null &&
|
||||
showUncategorized)
|
||||
_buildUncategorizedHook(),
|
||||
_buildTrashHook(),
|
||||
if (showTrash) _buildTrashHook(),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -254,9 +289,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.withOpacity(0.3),
|
||||
color: Theme.of(context).colorScheme.surface.withAlpha(30),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).dividerColor.withOpacity(0.5),
|
||||
color: Theme.of(context).dividerColor.withAlpha(50),
|
||||
width: 0.5,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(12.0),
|
||||
@@ -265,11 +300,8 @@ class _AllCollectionsPageState extends State<AllCollectionsPage>
|
||||
children: [
|
||||
Icon(
|
||||
Icons.delete_outline,
|
||||
color: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyLarge
|
||||
?.color
|
||||
?.withOpacity(0.7),
|
||||
color:
|
||||
Theme.of(context).textTheme.bodyLarge?.color?.withAlpha(70),
|
||||
size: 22,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
@@ -287,7 +319,7 @@ class _AllCollectionsPageState extends State<AllCollectionsPage>
|
||||
.textTheme
|
||||
.bodyMedium
|
||||
?.color
|
||||
?.withOpacity(0.6),
|
||||
?.withAlpha(60),
|
||||
size: 20,
|
||||
),
|
||||
],
|
||||
@@ -326,9 +358,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.withOpacity(0.3),
|
||||
color: Theme.of(context).colorScheme.surface.withAlpha(30),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).dividerColor.withOpacity(0.5),
|
||||
color: Theme.of(context).dividerColor.withAlpha(50),
|
||||
width: 0.5,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(12.0),
|
||||
@@ -337,11 +369,8 @@ class _AllCollectionsPageState extends State<AllCollectionsPage>
|
||||
children: [
|
||||
Icon(
|
||||
Icons.folder_open_outlined,
|
||||
color: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyLarge
|
||||
?.color
|
||||
?.withOpacity(0.7),
|
||||
color:
|
||||
Theme.of(context).textTheme.bodyLarge?.color?.withAlpha(70),
|
||||
size: 22,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
@@ -363,7 +392,7 @@ class _AllCollectionsPageState extends State<AllCollectionsPage>
|
||||
.textTheme
|
||||
.bodySmall
|
||||
?.color
|
||||
?.withOpacity(0.5),
|
||||
?.withAlpha(50),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
@@ -374,7 +403,7 @@ class _AllCollectionsPageState extends State<AllCollectionsPage>
|
||||
.textTheme
|
||||
.bodySmall
|
||||
?.color
|
||||
?.withOpacity(0.7),
|
||||
?.withAlpha(70),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -387,7 +416,7 @@ class _AllCollectionsPageState extends State<AllCollectionsPage>
|
||||
.textTheme
|
||||
.bodyMedium
|
||||
?.color
|
||||
?.withOpacity(0.6),
|
||||
?.withAlpha(60),
|
||||
size: 20,
|
||||
),
|
||||
],
|
||||
@@ -405,4 +434,15 @@ 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,17 +1,27 @@
|
||||
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;
|
||||
@@ -27,9 +37,16 @@ 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() {
|
||||
@@ -51,7 +68,9 @@ class _CollectionPageState extends UploaderPageState<CollectionPage>
|
||||
|
||||
@override
|
||||
void onSearchResultsChanged(
|
||||
List<Collection> collections, List<EnteFile> files,) {
|
||||
List<Collection> collections,
|
||||
List<EnteFile> files,
|
||||
) {
|
||||
setState(() {
|
||||
_filteredFiles = files;
|
||||
});
|
||||
@@ -66,6 +85,12 @@ class _CollectionPageState extends UploaderPageState<CollectionPage>
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_collectionUpdateSubscription.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
List<EnteFile> get _displayedFiles =>
|
||||
isSearchActive ? _filteredFiles : _files;
|
||||
|
||||
@@ -73,14 +98,40 @@ class _CollectionPageState extends UploaderPageState<CollectionPage>
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initializeData(widget.collection);
|
||||
Bus.instance.on<CollectionsUpdatedEvent>().listen((event) async {
|
||||
final collection = (await CollectionService.instance.getCollections())
|
||||
.where(
|
||||
(c) => c.id == widget.collection.id,
|
||||
)
|
||||
.first;
|
||||
await _initializeData(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');
|
||||
}
|
||||
});
|
||||
|
||||
collectionViewType = getCollectionViewType(
|
||||
_collection,
|
||||
Configuration.instance.getUserID()!,
|
||||
);
|
||||
|
||||
showFAB = collectionViewType == CollectionViewType.ownedCollection ||
|
||||
collectionViewType == CollectionViewType.hiddenOwnedCollection ||
|
||||
collectionViewType == CollectionViewType.quickLink;
|
||||
}
|
||||
|
||||
Future<void> _initializeData(Collection collection) async {
|
||||
@@ -112,6 +163,48 @@ 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(
|
||||
@@ -139,6 +232,14 @@ class _CollectionPageState extends UploaderPageState<CollectionPage>
|
||||
actions: [
|
||||
buildSearchAction(),
|
||||
...buildSearchActions(),
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
Icons.adaptive.share,
|
||||
),
|
||||
onPressed: () async {
|
||||
await _shareCollection();
|
||||
},
|
||||
),
|
||||
_buildMenuButton(),
|
||||
],
|
||||
);
|
||||
@@ -155,33 +256,53 @@ class _CollectionPageState extends UploaderPageState<CollectionPage>
|
||||
case 'delete':
|
||||
_deleteCollection();
|
||||
break;
|
||||
case 'leave_collection':
|
||||
_leaveCollection();
|
||||
break;
|
||||
}
|
||||
},
|
||||
itemBuilder: (BuildContext context) {
|
||||
return [
|
||||
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: 'edit',
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.edit),
|
||||
const SizedBox(width: 12),
|
||||
Text(context.l10n.edit),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
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.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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
];
|
||||
},
|
||||
);
|
||||
@@ -266,10 +387,12 @@ class _CollectionPageState extends UploaderPageState<CollectionPage>
|
||||
}
|
||||
|
||||
Widget _buildFAB() {
|
||||
return FloatingActionButton(
|
||||
onPressed: addFile,
|
||||
tooltip: context.l10n.addFiles,
|
||||
child: const Icon(Icons.add),
|
||||
);
|
||||
return showFAB
|
||||
? FloatingActionButton(
|
||||
onPressed: addFile,
|
||||
tooltip: context.l10n.addFiles,
|
||||
child: const Icon(Icons.add),
|
||||
)
|
||||
: const SizedBox.shrink();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,6 +15,8 @@ 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';
|
||||
@@ -24,7 +26,6 @@ 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 {
|
||||
@@ -50,7 +51,13 @@ class _HomePageState extends UploaderPageState<HomePage>
|
||||
List<Collection> _filteredCollections = [];
|
||||
List<EnteFile> _recentFiles = [];
|
||||
List<EnteFile> _filteredFiles = [];
|
||||
Map<int, int> _collectionFileCounts = {};
|
||||
List<Collection> outgoingCollections = [];
|
||||
List<Collection> incomingCollections = [];
|
||||
List<Collection> quickLinks = [];
|
||||
Map<int, int> _outgoingCollectionFileCounts = {};
|
||||
Map<int, int> _incomingCollectionFileCounts = {};
|
||||
Map<int, int> _homeCollectionFileCounts = {};
|
||||
|
||||
String? _error;
|
||||
final _logger = Logger('HomePage');
|
||||
StreamSubscription? _mediaStreamSubscription;
|
||||
@@ -88,7 +95,17 @@ class _HomePageState extends UploaderPageState<HomePage>
|
||||
}
|
||||
|
||||
List<Collection> get _displayedCollections {
|
||||
final collections = isSearchActive ? _filteredCollections : _collections;
|
||||
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();
|
||||
}
|
||||
return _filterOutUncategorized(collections);
|
||||
}
|
||||
|
||||
@@ -268,10 +285,16 @@ 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;
|
||||
});
|
||||
|
||||
@@ -491,10 +514,26 @@ class _HomePageState extends UploaderPageState<HomePage>
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildCollectionsHeader(),
|
||||
const SizedBox(height: 24),
|
||||
_buildCollectionsGrid(),
|
||||
const SizedBox(height: 24),
|
||||
..._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,
|
||||
),
|
||||
_buildRecentsSection(),
|
||||
],
|
||||
),
|
||||
@@ -557,105 +596,6 @@ 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,
|
||||
@@ -790,22 +730,79 @@ class _HomePageState extends UploaderPageState<HomePage>
|
||||
}
|
||||
|
||||
Future<void> _loadCollectionFileCounts() async {
|
||||
final counts = <int, int>{};
|
||||
final mainCounts = <int, int>{};
|
||||
final outgoingCounts = <int, int>{};
|
||||
final incomingCounts = <int, int>{};
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}),
|
||||
]);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_collectionFileCounts = counts;
|
||||
_homeCollectionFileCounts = mainCounts;
|
||||
_outgoingCollectionFileCounts = outgoingCounts;
|
||||
_incomingCollectionFileCounts = incomingCounts;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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,6 +4,7 @@ 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";
|
||||
@@ -11,6 +12,7 @@ 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";
|
||||
@@ -135,6 +137,35 @@ 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.shouldShowLockScreen()) {
|
||||
if (await LockScreenSettings.instance.isDeviceSupported()) {
|
||||
final bool result = await requestAuthentication(
|
||||
context,
|
||||
context.l10n.authToChangeLockscreenSetting,
|
||||
@@ -137,19 +137,17 @@ class _SecuritySectionWidgetState extends State<SecuritySectionWidget> {
|
||||
);
|
||||
}
|
||||
} else {
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (BuildContext context) {
|
||||
return const LockScreenOptions();
|
||||
},
|
||||
),
|
||||
await showErrorDialog(
|
||||
context,
|
||||
context.l10n.noSystemLockFound,
|
||||
context.l10n.toEnableAppLockPleaseSetupDevicePasscodeOrScreen,
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
sectionOptionSpacing,
|
||||
]);
|
||||
|
||||
|
||||
return Column(
|
||||
children: children,
|
||||
);
|
||||
|
||||
471
mobile/apps/locker/lib/ui/sharing/add_participant_page.dart
Normal file
@@ -0,0 +1,471 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
310
mobile/apps/locker/lib/ui/sharing/album_participants_page.dart
Normal file
@@ -0,0 +1,310 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
104
mobile/apps/locker/lib/ui/sharing/album_share_info_widget.dart
Normal file
@@ -0,0 +1,104 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
188
mobile/apps/locker/lib/ui/sharing/manage_album_participant.dart
Normal file
@@ -0,0 +1,188 @@
|
||||
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);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
353
mobile/apps/locker/lib/ui/sharing/manage_links_widget.dart
Normal file
@@ -0,0 +1,353 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
79
mobile/apps/locker/lib/ui/sharing/more_count_badge.dart
Normal file
@@ -0,0 +1,79 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
404
mobile/apps/locker/lib/ui/sharing/share_collection_page.dart
Normal file
@@ -0,0 +1,404 @@
|
||||
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,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
227
mobile/apps/locker/lib/ui/viewer/date/date_time_picker.dart
Normal file
@@ -0,0 +1,227 @@
|
||||
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,10 +1,24 @@
|
||||
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/collections/models/collection.dart';
|
||||
import "package:locker/services/configuration.dart";
|
||||
import 'package:locker/utils/snack_bar_utils.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
@@ -157,4 +171,336 @@ 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: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: bip39
|
||||
sha256: de1ee27ebe7d96b84bb3a04a4132a0a3007dcdd5ad27dd14aa87a29d97c45edc
|
||||
@@ -146,7 +146,7 @@ packages:
|
||||
source: hosted
|
||||
version: "0.3.4+2"
|
||||
crypto:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: crypto
|
||||
sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855"
|
||||
@@ -202,7 +202,7 @@ packages:
|
||||
source: hosted
|
||||
version: "2.1.1"
|
||||
dotted_border:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: dotted_border
|
||||
sha256: "99b091ec6891ba0c5331fdc2b502993c7c108f898995739a73c6845d71dad70c"
|
||||
@@ -254,6 +254,13 @@ 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:
|
||||
@@ -275,6 +282,13 @@ 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:
|
||||
@@ -555,6 +569,14 @@ 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
|
||||
@@ -606,7 +628,7 @@ packages:
|
||||
source: hosted
|
||||
version: "0.15.6"
|
||||
http:
|
||||
dependency: "direct main"
|
||||
dependency: transitive
|
||||
description:
|
||||
name: http
|
||||
sha256: "2c11f3f94c687ee9bad77c171151672986360b2b001d109814ee7140b2cf261b"
|
||||
@@ -638,7 +660,7 @@ packages:
|
||||
source: hosted
|
||||
version: "4.5.4"
|
||||
intl:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: intl
|
||||
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
|
||||
@@ -933,6 +955,14 @@ 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:
|
||||
@@ -1291,7 +1321,7 @@ packages:
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
styled_text:
|
||||
dependency: "direct main"
|
||||
dependency: transitive
|
||||
description:
|
||||
name: styled_text
|
||||
sha256: fd624172cf629751b4f171dd0ecf9acf02a06df3f8a81bb56c0caa4f1df706c3
|
||||
@@ -1331,7 +1361,7 @@ packages:
|
||||
source: hosted
|
||||
version: "0.5.0"
|
||||
tuple:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: tuple
|
||||
sha256: a97ce2013f240b2f3807bcbaf218765b6f301c3eff91092bcfa23a039e7dd151
|
||||
@@ -1434,6 +1464,30 @@ 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:
|
||||
@@ -1524,4 +1578,4 @@ packages:
|
||||
version: "3.1.3"
|
||||
sdks:
|
||||
dart: ">=3.8.0 <4.0.0"
|
||||
flutter: ">=3.27.0"
|
||||
flutter: ">=3.29.0"
|
||||
|
||||
@@ -8,8 +8,11 @@ environment:
|
||||
|
||||
dependencies:
|
||||
adaptive_theme: ^3.6.0
|
||||
bip39: ^1.0.6
|
||||
collection: ^1.18.0
|
||||
dio: ^5.8.0+1
|
||||
crypto: ^3.0.6
|
||||
dio: ^5.8.0+1
|
||||
dotted_border: ^3.1.0
|
||||
email_validator: ^3.0.0
|
||||
ente_accounts:
|
||||
path: ../../packages/accounts
|
||||
@@ -22,19 +25,23 @@ 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
|
||||
@@ -46,8 +53,9 @@ dependencies:
|
||||
url: https://github.com/eaceto/flutter_local_authentication
|
||||
ref: 1ac346a04592a05fd75acccf2e01fa3c7e955d96
|
||||
flutter_localizations:
|
||||
sdk: flutter
|
||||
http: ^1.4.0
|
||||
sdk: flutter
|
||||
flutter_svg: ^2.2.1
|
||||
intl: ^0.20.2
|
||||
io: ^1.0.5
|
||||
listen_sharing_intent: ^1.9.2
|
||||
logging: ^1.3.0
|
||||
@@ -56,12 +64,12 @@ dependencies:
|
||||
path: ^1.9.0
|
||||
path_provider: ^2.1.5
|
||||
shared_preferences: ^2.5.3
|
||||
sqflite: ^2.4.1
|
||||
styled_text: ^8.1.0
|
||||
sqflite: ^2.4.1
|
||||
tray_manager: ^0.5.0
|
||||
tuple: ^2.0.2
|
||||
url_launcher: ^6.3.2
|
||||
uuid: ^4.5.1
|
||||
window_manager: ^0.5.0
|
||||
uuid: ^4.5.1
|
||||
window_manager: ^0.5.1
|
||||
|
||||
dev_dependencies:
|
||||
flutter_launcher_icons: ^0.14.3
|
||||
@@ -75,6 +83,7 @@ flutter:
|
||||
|
||||
assets:
|
||||
- assets/
|
||||
- assets/icons/
|
||||
|
||||
fonts:
|
||||
- family: Inter
|
||||
|
||||
@@ -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
|
||||
# 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
|
||||
dependency_overrides:
|
||||
ente_accounts:
|
||||
path: ../../packages/accounts
|
||||
@@ -8,12 +8,16 @@ 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:
|
||||
|
||||
208
mobile/apps/photos/CLAUDE.md
Normal file
@@ -0,0 +1,208 @@
|
||||
# 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. Format Dart code
|
||||
dart format .
|
||||
|
||||
# 2. Analyze flutter code for errors and warnings
|
||||
flutter analyze
|
||||
```
|
||||
|
||||
**Why CI might fail even after running these:**
|
||||
|
||||
- Skipping any command above
|
||||
- Assuming auto-fix tools handle everything (they don't)
|
||||
- Not fixing warnings that flutter reports
|
||||
- Making changes after running the checks
|
||||
|
||||
### Commit & PR Message Rules
|
||||
|
||||
**These rules apply to BOTH commit messages AND pull request descriptions**
|
||||
|
||||
- Keep messages CONCISE (no walls of text)
|
||||
- Subject line under 72 chars (no body text unless critical)
|
||||
- NO emojis
|
||||
- NO promotional text or links (except Co-Authored-By line)
|
||||
|
||||
### Additional Guidelines
|
||||
|
||||
- Check `git status` before committing to avoid adding temporary/binary files
|
||||
- Never commit to main branch
|
||||
- All CI checks must pass - run the checklist commands above before committing or creating PR
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Using Melos (Monorepo Management)
|
||||
```bash
|
||||
# From mobile/ directory - bootstrap all packages
|
||||
melos bootstrap
|
||||
|
||||
# Run Photos app specifically
|
||||
melos run:photos:apk
|
||||
|
||||
# Build Photos APK
|
||||
melos build:photos:apk
|
||||
|
||||
# Clean Photos app
|
||||
melos clean:photos
|
||||
```
|
||||
|
||||
### Direct Flutter Commands
|
||||
```bash
|
||||
# Development run with environment variables
|
||||
./run.sh # Uses .env file with --flavor dev
|
||||
|
||||
# Development run without env file
|
||||
flutter run -t lib/main.dart --flavor independent
|
||||
|
||||
# Build release APK
|
||||
flutter build apk --release --flavor independent
|
||||
|
||||
# iOS build
|
||||
cd ios && pod install && cd ..
|
||||
flutter build ios
|
||||
```
|
||||
|
||||
### Code Quality
|
||||
```bash
|
||||
# Static analysis and linting
|
||||
flutter analyze .
|
||||
|
||||
# Run tests
|
||||
flutter test
|
||||
```
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
### Service-Oriented Architecture
|
||||
The app uses a service layer pattern with 28+ specialized services:
|
||||
- **collections_service.dart**: Album and collection management
|
||||
- **search_service.dart**: Search functionality with ML support
|
||||
- **smart_memories_service.dart**: AI-powered memory curation
|
||||
- **sync_service.dart**: Local/remote synchronization
|
||||
- **Machine Learning Services**: Face recognition, semantic search, similar images
|
||||
|
||||
### Key Patterns
|
||||
- **Service Locator**: Dependency injection via `lib/service_locator.dart`
|
||||
- **Event Bus**: Loose coupling via `lib/core/event_bus.dart`
|
||||
- **Repository Pattern**: Database abstraction in `lib/db/`
|
||||
- **Rust Integration**: Performance-critical operations via Flutter Rust Bridge
|
||||
|
||||
### Security Architecture
|
||||
- End-to-end encryption with `ente_crypto` package
|
||||
- BIP39 mnemonic-based key generation (24 words)
|
||||
- Secure storage using platform-specific implementations
|
||||
- App lock and privacy screen features
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
lib/
|
||||
├── core/ # Configuration, constants, networking
|
||||
├── services/ # Business logic (28+ services)
|
||||
├── ui/ # UI components (18 subdirectories)
|
||||
├── models/ # Data models (17 subdirectories)
|
||||
├── db/ # SQLite database layer
|
||||
├── utils/ # Utilities and helpers
|
||||
├── gateways/ # API gateway interfaces
|
||||
├── events/ # Event system
|
||||
├── l10n/ # Localization files (intl_*.arb)
|
||||
└── generated/ # Auto-generated code including localizations
|
||||
```
|
||||
|
||||
## Localization (Flutter)
|
||||
|
||||
- Add new strings to `lib/l10n/intl_en.arb` (English base file)
|
||||
- Use `AppLocalizations` to access localized strings in code
|
||||
- Example: `AppLocalizations.of(context).yourStringKey`
|
||||
- Run code generation after adding new strings: `flutter pub get`
|
||||
- Translations managed via Crowdin for other languages
|
||||
|
||||
## Key Dependencies
|
||||
|
||||
- **Flutter 3.32.8** with Dart SDK >=3.3.0 <4.0.0
|
||||
- **Media**: `photo_manager`, `video_editor`, `ffmpeg_kit_flutter`
|
||||
- **Storage**: `sqlite_async`, `flutter_secure_storage`
|
||||
- **ML/AI**: Custom ONNX runtime, `ml_linalg`
|
||||
- **Rust**: Flutter Rust Bridge for performance
|
||||
|
||||
## Development Setup Requirements
|
||||
|
||||
1. Install Flutter v3.32.8 and Rust
|
||||
2. Install Flutter Rust Bridge: `cargo install flutter_rust_bridge_codegen`
|
||||
3. Generate Rust bindings: `flutter_rust_bridge_codegen generate`
|
||||
4. Update submodules: `git submodule update --init --recursive`
|
||||
5. Enable git hooks: `git config core.hooksPath hooks`
|
||||
|
||||
## Critical Coding Requirements
|
||||
|
||||
### 1. Code Quality - MANDATORY
|
||||
**Every code change MUST pass `dart format .` and `flutter analyze` with zero issues**
|
||||
- Run `dart format .` first to format all Dart code
|
||||
- Run `flutter analyze` after EVERY code modification
|
||||
- Resolve ALL issues (info, warning, error) - no exceptions
|
||||
- The codebase has zero issues by default, so any issue is from your changes
|
||||
- DO NOT commit or consider work complete until both commands pass cleanly
|
||||
|
||||
### 2. Component Reuse - MANDATORY
|
||||
**Always try to reuse existing components**
|
||||
- Use a subagent to search for existing components before creating new ones
|
||||
- Only create new components if none exist that meet the requirements
|
||||
- Check both UI components in `lib/ui/` and shared components in `../../packages/`
|
||||
|
||||
### 3. Design System - MANDATORY
|
||||
**Never hardcode colors or text styles**
|
||||
- Always use the Ente design system for colors and typography
|
||||
- Use a subagent to find the appropriate design tokens
|
||||
- Access colors via theme: `getEnteColorScheme(context)`
|
||||
- Access text styles via theme: `getEnteTextTheme(context)`
|
||||
- Call above theme getters only at the top of (`build`) methods and re-use them throughout the component
|
||||
- If you MUST use custom colors/styles (extremely rare), explicitly inform the user with a clear warning
|
||||
|
||||
### 4. Documentation Sync - MANDATORY
|
||||
**Keep spec documents synchronized with code changes**
|
||||
- When modifying code, also update any associated spec documents
|
||||
- Check for related spec files in `docs/` or project directories
|
||||
- Ensure documentation reflects the current implementation
|
||||
- Update examples in specs if behavior changes
|
||||
|
||||
## 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
|
||||
@@ -181,6 +181,8 @@ PODS:
|
||||
- PromisesObjC (2.4.0)
|
||||
- receive_sharing_intent (1.8.1):
|
||||
- Flutter
|
||||
- rive_common (0.0.1):
|
||||
- Flutter
|
||||
- rust_lib_photos (0.0.1):
|
||||
- Flutter
|
||||
- SDWebImage (5.21.1):
|
||||
@@ -291,6 +293,7 @@ DEPENDENCIES:
|
||||
- photo_manager (from `.symlinks/plugins/photo_manager/ios`)
|
||||
- privacy_screen (from `.symlinks/plugins/privacy_screen/ios`)
|
||||
- receive_sharing_intent (from `.symlinks/plugins/receive_sharing_intent/ios`)
|
||||
- rive_common (from `.symlinks/plugins/rive_common/ios`)
|
||||
- rust_lib_photos (from `.symlinks/plugins/rust_lib_photos/ios`)
|
||||
- sentry_flutter (from `.symlinks/plugins/sentry_flutter/ios`)
|
||||
- share_plus (from `.symlinks/plugins/share_plus/ios`)
|
||||
@@ -309,7 +312,7 @@ DEPENDENCIES:
|
||||
- workmanager (from `.symlinks/plugins/workmanager/ios`)
|
||||
|
||||
SPEC REPOS:
|
||||
https://github.com/ente-io/ffmpeg-kit-custom-repo-ios:
|
||||
https://github.com/ente-io/ffmpeg-kit-custom-repo-ios.git:
|
||||
- ffmpeg_kit_custom
|
||||
trunk:
|
||||
- Firebase
|
||||
@@ -418,6 +421,8 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/privacy_screen/ios"
|
||||
receive_sharing_intent:
|
||||
:path: ".symlinks/plugins/receive_sharing_intent/ios"
|
||||
rive_common:
|
||||
:path: ".symlinks/plugins/rive_common/ios"
|
||||
rust_lib_photos:
|
||||
:path: ".symlinks/plugins/rust_lib_photos/ios"
|
||||
sentry_flutter:
|
||||
@@ -452,84 +457,85 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/workmanager/ios"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
app_links: 76b66b60cc809390ca1ad69bfd66b998d2387ac7
|
||||
battery_info: 83f3aae7be2fccefab1d2bf06b8aa96f11c8bcdd
|
||||
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
|
||||
cupertino_http: 94ac07f5ff090b8effa6c5e2c47871d48ab7c86c
|
||||
dart_ui_isolate: 46f6714abe6891313267153ef6f9748d8ecfcab1
|
||||
device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
|
||||
emoji_picker_flutter: ed468d9746c21711e66b2788880519a9de5de211
|
||||
app_links: f3e17e4ee5e357b39d8b95290a9b2c299fca71c6
|
||||
battery_info: b6c551049266af31556b93c9d9b9452cfec0219f
|
||||
connectivity_plus: 2a701ffec2c0ae28a48cf7540e279787e77c447d
|
||||
cupertino_http: 947a233f40cfea55167a49f2facc18434ea117ba
|
||||
dart_ui_isolate: d5bcda83ca4b04f129d70eb90110b7a567aece14
|
||||
device_info_plus: bf2e3232933866d73fe290f2942f2156cdd10342
|
||||
emoji_picker_flutter: fe2e6151c5b548e975d546e6eeb567daf0962a58
|
||||
ffmpeg_kit_custom: 682b4f2f1ff1f8abae5a92f6c3540f2441d5be99
|
||||
ffmpeg_kit_flutter: 915b345acc97d4142e8a9a8549d177ff10f043f5
|
||||
file_saver: 6cdbcddd690cb02b0c1a0c225b37cd805c2bf8b6
|
||||
ffmpeg_kit_flutter: 9dce4803991478c78c6fb9f972703301101095fe
|
||||
file_saver: 503e386464dbe118f630e17b4c2e1190fa0cf808
|
||||
Firebase: d99ac19b909cd2c548339c2241ecd0d1599ab02e
|
||||
firebase_core: ece862f94b2bc72ee0edbeec7ab5c7cb09fe1ab5
|
||||
firebase_messaging: e1a5fae495603115be1d0183bc849da748734e2b
|
||||
firebase_core: cf4d42a8ac915e51c0c2dc103442f3036d941a2d
|
||||
firebase_messaging: fee490327c1aae28a0da1e65fca856547deca493
|
||||
FirebaseCore: efb3893e5b94f32b86e331e3bd6dadf18b66568e
|
||||
FirebaseCoreInternal: 9afa45b1159304c963da48addb78275ef701c6b4
|
||||
FirebaseInstallations: 317270fec08a5d418fdbc8429282238cab3ac843
|
||||
FirebaseMessaging: 3b26e2cee503815e01c3701236b020aa9b576f09
|
||||
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
|
||||
flutter_email_sender: aa1e9772696691d02cd91fea829856c11efb8e58
|
||||
flutter_image_compress_common: 1697a328fd72bfb335507c6bca1a65fa5ad87df1
|
||||
flutter_inappwebview_ios: b89ba3482b96fb25e00c967aae065701b66e9b99
|
||||
flutter_local_notifications: a5a732f069baa862e728d839dd2ebb904737effb
|
||||
flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf
|
||||
flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13
|
||||
flutter_sodium: 7e4621538491834eba53bd524547854bcbbd6987
|
||||
flutter_timezone: 7c838e17ffd4645d261e87037e5bebf6d38fe544
|
||||
fluttertoast: 2c67e14dce98bbdb200df9e1acf610d7a6264ea1
|
||||
flutter_email_sender: e03bdda7637bcd3539bfe718fddd980e9508efaa
|
||||
flutter_image_compress_common: ec1d45c362c9d30a3f6a0426c297f47c52007e3e
|
||||
flutter_inappwebview_ios: 6f63631e2c62a7c350263b13fa5427aedefe81d4
|
||||
flutter_local_notifications: ff50f8405aaa0ccdc7dcfb9022ca192e8ad9688f
|
||||
flutter_native_splash: df59bb2e1421aa0282cb2e95618af4dcb0c56c29
|
||||
flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12
|
||||
flutter_sodium: a00383520fc689c688b66fd3092984174712493e
|
||||
flutter_timezone: ac3da59ac941ff1c98a2e1f0293420e020120282
|
||||
fluttertoast: 21eecd6935e7064cc1fcb733a4c5a428f3f24f0f
|
||||
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
|
||||
GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1
|
||||
home_widget: f169fc41fd807b4d46ab6615dc44d62adbf9f64f
|
||||
in_app_purchase_storekit: d1a48cb0f8b29dbf5f85f782f5dd79b21b90a5e6
|
||||
integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e
|
||||
launcher_icon_switcher: 84c218d233505aa7d8655d8fa61a3ba802c022da
|
||||
home_widget: 0434835a4c9a75704264feff6be17ea40e0f0d57
|
||||
in_app_purchase_storekit: a1ce04056e23eecc666b086040239da7619cd783
|
||||
integration_test: 252f60fa39af5e17c3aa9899d35d908a0721b573
|
||||
launcher_icon_switcher: 8e0ad2131a20c51c1dd939896ee32e70cd845b37
|
||||
libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8
|
||||
local_auth_ios: f7a1841beef3151d140a967c2e46f30637cdf451
|
||||
local_auth_ios: 5046a18c018dd973247a0564496c8898dbb5adf9
|
||||
Mantle: c5aa8794a29a022dfbbfc9799af95f477a69b62d
|
||||
maps_launcher: edf829809ba9e894d70e569bab11c16352dedb45
|
||||
media_extension: 671e2567880d96c95c65c9a82ccceed8f2e309fd
|
||||
media_kit_libs_ios_video: 5a18affdb97d1f5d466dc79988b13eff6c5e2854
|
||||
media_kit_video: 1746e198cb697d1ffb734b1d05ec429d1fcd1474
|
||||
motion_sensors: 741e702c17467b9569a92165dda8d4d88c6167f1
|
||||
motionphoto: 23e2aeb5c6380112f69468d71f970fa7438e5ed1
|
||||
move_to_background: 7e3467dd2a1d1013e98c9c1cb93fd53cd7ef9d84
|
||||
maps_launcher: 2e5b6a2d664ec6c27f82ffa81b74228d770ab203
|
||||
media_extension: 6618f07abd762cdbfaadf1b0c56a287e820f0c84
|
||||
media_kit_libs_ios_video: a5fe24bc7875ccd6378a0978c13185e1344651c1
|
||||
media_kit_video: 5da63f157170e5bf303bf85453b7ef6971218a2e
|
||||
motion_sensors: 03f55b7c637a7e365a0b5f9697a449f9059d5d91
|
||||
motionphoto: 8b65ce50c7d7ff3c767534fc3768b2eed9ac24e4
|
||||
move_to_background: cd3091014529ec7829e342ad2d75c0a11f4378a5
|
||||
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
|
||||
native_video_player: 6809dec117e8997161dbfb42a6f90d6df71a504d
|
||||
objective_c: 89e720c30d716b036faf9c9684022048eee1eee2
|
||||
onnxruntime: f9b296392c96c42882be020a59dbeac6310d81b2
|
||||
native_video_player: 29ab24a926804ac8c4a57eb6d744c7d927c2bc3e
|
||||
objective_c: 77e887b5ba1827970907e10e832eec1683f3431d
|
||||
onnxruntime: e7c2ae44385191eaad5ae64c935a72debaddc997
|
||||
onnxruntime-c: a909204639a1f035f575127ac406f781ac797c9c
|
||||
onnxruntime-objc: b6fab0f1787aa6f7190c2013f03037df4718bd8b
|
||||
open_mail_app: 7314a609e88eed22d53671279e189af7a0ab0f11
|
||||
open_mail_app: 70273c53f768beefdafbe310c3d9086e4da3cb02
|
||||
OrderedSet: e539b66b644ff081c73a262d24ad552a69be3a94
|
||||
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
|
||||
path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564
|
||||
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
|
||||
photo_manager: 1d80ae07a89a67dfbcae95953a1e5a24af7c3e62
|
||||
privacy_screen: 3159a541f5d3a31bea916cfd4e58f9dc722b3fd4
|
||||
package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4
|
||||
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
|
||||
permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2
|
||||
photo_manager: 81954a1bf804b6e882d0453b3b6bc7fad7b47d3d
|
||||
privacy_screen: 1a131c052ceb3c3659934b003b0d397c2381a24e
|
||||
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
|
||||
receive_sharing_intent: 222384f00ffe7e952bbfabaa9e3967cb87e5fe00
|
||||
rust_lib_photos: 04d3901908d2972192944083310b65abf410774c
|
||||
receive_sharing_intent: 79c848f5b045674ad60b9fea3bafea59962ad2c1
|
||||
rive_common: 4743dbfd2911c99066547a3c6454681e0fa907df
|
||||
rust_lib_photos: 8813b31af48ff02ca75520cbc81a363a13d51a84
|
||||
SDWebImage: f29024626962457f3470184232766516dee8dfea
|
||||
SDWebImageWebPCoder: e38c0a70396191361d60c092933e22c20d5b1380
|
||||
Sentry: da60d980b197a46db0b35ea12cb8f39af48d8854
|
||||
sentry_flutter: 27892878729f42701297c628eb90e7c6529f3684
|
||||
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
|
||||
shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7
|
||||
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
|
||||
sentry_flutter: 2df8b0aab7e4aba81261c230cbea31c82a62dd1b
|
||||
share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f
|
||||
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
|
||||
sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
|
||||
sqlite3: 1d85290c3321153511f6e900ede7a1608718bbd5
|
||||
sqlite3_flutter_libs: e7fc8c9ea2200ff3271f08f127842131746b70e2
|
||||
system_info_plus: 555ce7047fbbf29154726db942ae785c29211740
|
||||
thermal: d4c48be750d1ddbab36b0e2dcb2471531bc8df41
|
||||
ua_client_hints: 92fe0d139619b73ec9fcb46cc7e079a26178f586
|
||||
url_launcher_ios: 694010445543906933d732453a59da0a173ae33d
|
||||
vibration: 8e2f50fc35bb736f9eecb7dd9f7047fbb6a6e888
|
||||
video_player_avfoundation: 2cef49524dd1f16c5300b9cd6efd9611ce03639b
|
||||
video_thumbnail: b637e0ad5f588ca9945f6e2c927f73a69a661140
|
||||
volume_controller: 3657a1f65bedb98fa41ff7dc5793537919f31b12
|
||||
wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556
|
||||
workmanager: b89e4e4445d8b57ee2fdbf1c3925696ebe5b8990
|
||||
sqlite3_flutter_libs: 2c48c4ee7217fd653251975e43412250d5bcbbe2
|
||||
system_info_plus: 5393c8da281d899950d751713575fbf91c7709aa
|
||||
thermal: a9261044101ae8f532fa29cab4e8270b51b3f55c
|
||||
ua_client_hints: aeabd123262c087f0ce151ef96fa3ab77bfc8b38
|
||||
url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe
|
||||
vibration: 7d883d141656a1c1a6d8d238616b2042a51a1241
|
||||
video_player_avfoundation: 7c6c11d8470e1675df7397027218274b6d2360b3
|
||||
video_thumbnail: c4e2a3c539e247d4de13cd545344fd2d26ffafd1
|
||||
volume_controller: 2e3de73d6e7e81a0067310d17fb70f2f86d71ac7
|
||||
wakelock_plus: 76957ab028e12bfa4e66813c99e46637f367fc7e
|
||||
workmanager: 05afacf221f5086e18450250dce57f59bb23e6b0
|
||||
|
||||
PODFILE CHECKSUM: cce2cd3351d3488dca65b151118552b680e23635
|
||||
|
||||
|
||||
@@ -565,6 +565,7 @@
|
||||
"${BUILT_PRODUCTS_DIR}/photo_manager/photo_manager.framework",
|
||||
"${BUILT_PRODUCTS_DIR}/privacy_screen/privacy_screen.framework",
|
||||
"${BUILT_PRODUCTS_DIR}/receive_sharing_intent/receive_sharing_intent.framework",
|
||||
"${BUILT_PRODUCTS_DIR}/rive_common/rive_common.framework",
|
||||
"${BUILT_PRODUCTS_DIR}/rust_lib_photos/rust_lib_photos.framework",
|
||||
"${BUILT_PRODUCTS_DIR}/sentry_flutter/sentry_flutter.framework",
|
||||
"${BUILT_PRODUCTS_DIR}/share_plus/share_plus.framework",
|
||||
@@ -662,6 +663,7 @@
|
||||
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/photo_manager.framework",
|
||||
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/privacy_screen.framework",
|
||||
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/receive_sharing_intent.framework",
|
||||
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/rive_common.framework",
|
||||
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/rust_lib_photos.framework",
|
||||
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/sentry_flutter.framework",
|
||||
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/share_plus.framework",
|
||||
|
||||
45
mobile/apps/photos/ios/ci_scripts/ci_post_clone.sh
Executable file
@@ -0,0 +1,45 @@
|
||||
#!/bin/sh
|
||||
|
||||
# ci_post_clone.sh
|
||||
# This script runs after the repository is cloned
|
||||
|
||||
set -e
|
||||
|
||||
echo "🚀 Starting post-clone setup..."
|
||||
|
||||
# Navigate to the Flutter project root
|
||||
cd ../../ # Adjust path based on your structure
|
||||
|
||||
# Install Flutter
|
||||
echo "📦 Installing Flutter..."
|
||||
FLUTTER_VERSION="3.32.8"
|
||||
git clone https://github.com/flutter/flutter.git --branch $FLUTTER_VERSION --depth 1 $HOME/flutter
|
||||
export PATH="$PATH:$HOME/flutter/bin"
|
||||
|
||||
# Verify Flutter installation
|
||||
flutter --version
|
||||
flutter doctor -v
|
||||
|
||||
# Install Rust (required for Flutter Rust Bridge)
|
||||
echo "🦀 Installing Rust..."
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
|
||||
source $HOME/.cargo/env
|
||||
export PATH="$HOME/.cargo/bin:$PATH"
|
||||
|
||||
# Install Flutter Rust Bridge
|
||||
echo "🌉 Installing Flutter Rust Bridge..."
|
||||
cargo install flutter_rust_bridge_codegen
|
||||
|
||||
# Generate Rust bindings
|
||||
echo "⚙️ Generating Rust bindings..."
|
||||
flutter_rust_bridge_codegen generate
|
||||
|
||||
# Get Flutter dependencies
|
||||
echo "📚 Getting Flutter dependencies..."
|
||||
flutter pub get
|
||||
|
||||
# Generate iOS podfile if needed
|
||||
cd ios
|
||||
pod install --repo-update
|
||||
|
||||
echo "✅ Post-clone setup completed!"
|
||||
0
mobile/apps/photos/ios/ci_scripts/ci_post_xcodebuild.sh
Executable file
25
mobile/apps/photos/ios/ci_scripts/ci_pre_xcodebuild.sh
Executable file
@@ -0,0 +1,25 @@
|
||||
#!/bin/sh
|
||||
|
||||
# ci_pre_xcodebuild.sh
|
||||
# This script runs before xcodebuild
|
||||
|
||||
set -e
|
||||
|
||||
echo "🔧 Pre-build setup starting..."
|
||||
|
||||
# Set up environment
|
||||
export PATH="$PATH:$HOME/flutter/bin"
|
||||
export PATH="$HOME/.cargo/bin:$PATH"
|
||||
source $HOME/.cargo/env || true
|
||||
|
||||
# Navigate to Flutter project root
|
||||
cd ../../../
|
||||
|
||||
# Build Flutter iOS framework
|
||||
echo "🏗️ Building Flutter framework..."
|
||||
flutter build ios-framework --no-debug --no-profile
|
||||
|
||||
# Run any code generation if needed
|
||||
flutter pub run build_runner build --delete-conflicting-outputs || true
|
||||
|
||||
echo "✅ Pre-build setup completed!"
|
||||
10
mobile/apps/photos/ios/xcode-cloud.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Release Build",
|
||||
"scheme": "Runner",
|
||||
"archive_configuration": "Release",
|
||||
"destination": "generic/platform=iOS"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,10 +39,26 @@ class ClipVectorDB {
|
||||
final documentsDirectory = await getApplicationDocumentsDirectory();
|
||||
final String dbPath = join(documentsDirectory.path, _databaseName);
|
||||
_logger.info("Opening vectorDB access: DB path " + dbPath);
|
||||
final vectorDB = VectorDb(
|
||||
filePath: dbPath,
|
||||
dimensions: _embeddingDimension,
|
||||
);
|
||||
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 stats = await getIndexStats(vectorDB);
|
||||
_logger.info("VectorDB connection opened with stats: ${stats.toString()}");
|
||||
|
||||
|
||||
@@ -228,7 +228,7 @@ extension CustomColorScheme on ColorScheme {
|
||||
Color get videoPlayerPrimaryColor => brightness == Brightness.light
|
||||
? const Color.fromRGBO(0, 179, 60, 1)
|
||||
: const Color.fromRGBO(1, 222, 77, 1);
|
||||
|
||||
|
||||
Color get videoPlayerBorderColor => brightness == Brightness.light
|
||||
? const Color(0xFF424242)
|
||||
: const Color(0xFFFFFFFF);
|
||||
|
||||
@@ -5,4 +5,4 @@ class CreateNewAlbumEvent extends Event {
|
||||
final Collection collection;
|
||||
|
||||
CreateNewAlbumEvent(this.collection);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1776,11 +1776,6 @@
|
||||
"same": "نفس",
|
||||
"different": "مختلف",
|
||||
"sameperson": "نفس الشخص؟",
|
||||
"cLTitle1": "محرر الصور المتقدم",
|
||||
"cLDesc1": "نحن بصدد إطلاق محرر صور جديد ومتقدم يضيف المزيد من إطارات الاقتصاص، والإعدادات المسبقة للفلاتر من أجل تعديلات سريعة، وخيارات الضبط الدقيق التي تشمل التشبع، والتباين، والسطوع، ودرجة الحرارة، وغير ذلك الكثير. يتضمن المحرر الجديد أيضا القدرة على الرسم على صورك وإضافة الرموز التعبيرية كملصقات.",
|
||||
"cLTitle2": "ألبومات ذكية",
|
||||
"cLTitle3": "معرض محسن",
|
||||
"cLTitle4": "تمرير أسرع",
|
||||
"thisWeek": "هذا الأسبوع",
|
||||
"lastWeek": "الأسبوع الماضي",
|
||||
"thisMonth": "هذا الشهر",
|
||||
@@ -1821,4 +1816,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1776,14 +1776,6 @@
|
||||
"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",
|
||||
@@ -1921,4 +1913,4 @@
|
||||
"similar": "Podobné",
|
||||
"identical": "Identické",
|
||||
"nothingHereTryAnotherFilter": "Tady nic není, zkuste jiný filtr! 👀"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1776,14 +1776,6 @@
|
||||
"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",
|
||||
@@ -1933,5 +1925,11 @@
|
||||
"nothingHereTryAnotherFilter": "Nichts zu sehen, probier einen anderen Filter! 👀",
|
||||
"related": "Verwandt",
|
||||
"hoorayyyy": "Hurraaaa!",
|
||||
"nothingToTidyUpHere": "Hier gibt es nichts zu bereinigen"
|
||||
"nothingToTidyUpHere": "Hier gibt es nichts zu bereinigen",
|
||||
"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 -> Sicherung -> Speicherplatz freigeben",
|
||||
"cLTitle2": "Video-Streaming-Verbesserungen",
|
||||
"cLDesc2": "Du kannst jetzt die Stream-Generierung für Videos direkt aus der App manuell auslösen. Wir haben auch einen neuen Video-Streaming-Einstellungsbildschirm hinzugefügt, der dir zeigt, welcher Prozentsatz deiner Videos für das Streaming verarbeitet wurde",
|
||||
"cLTitle3": "Leistungsverbesserungen",
|
||||
"cLDesc3": "Mehrere Verbesserungen im Hintergrund, einschließlich besserer Cache-Nutzung und einer flüssigeren Scroll-Erfahrung"
|
||||
}
|
||||
@@ -1776,14 +1776,6 @@
|
||||
"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",
|
||||
@@ -1939,5 +1931,11 @@
|
||||
"related": "Related",
|
||||
"hoorayyyy": "Hoorayyyy!",
|
||||
"nothingToTidyUpHere": "Nothing to tidy up here",
|
||||
"deletingDash": "Deleting - "
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -1776,14 +1776,6 @@
|
||||
"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",
|
||||
@@ -1933,5 +1925,11 @@
|
||||
"nothingHereTryAnotherFilter": "Nada aquí, ¡prueba con otro filtro! 👀",
|
||||
"related": "Relacionado",
|
||||
"hoorayyyy": "¡Hurraaaa!",
|
||||
"nothingToTidyUpHere": "Nada que limpiar aquí"
|
||||
"nothingToTidyUpHere": "Nada que limpiar aquí",
|
||||
"cLTitle1": "Imágenes similares",
|
||||
"cLDesc1": "Estamos introduciendo un nuevo sistema basado en ML para detectar imágenes similares, con el cual puedes limpiar tu biblioteca. Disponible en Configuración -> Copia de seguridad -> Liberar espacio",
|
||||
"cLTitle2": "Mejoras de transmisión de video",
|
||||
"cLDesc2": "Ahora puedes activar manualmente la generación de transmisión para videos directamente desde la aplicación. También hemos agregado una nueva pantalla de configuración de transmisión de video que te mostrará qué porcentaje de tus videos han sido procesados para transmisión",
|
||||
"cLTitle3": "Mejoras de rendimiento",
|
||||
"cLDesc3": "Múltiples mejoras internas, incluyendo mejor uso de caché y una experiencia de desplazamiento más fluida"
|
||||
}
|
||||
@@ -1776,14 +1776,6 @@
|
||||
"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",
|
||||
@@ -1925,5 +1917,11 @@
|
||||
"nothingHereTryAnotherFilter": "Rien ici, essayez un autre filtre ! 👀",
|
||||
"related": "Liés",
|
||||
"hoorayyyy": "Houraaa !",
|
||||
"nothingToTidyUpHere": "Rien à nettoyer ici"
|
||||
"nothingToTidyUpHere": "Rien à nettoyer ici",
|
||||
"cLTitle1": "Images similaires",
|
||||
"cLDesc1": "Nous introduisons un nouveau système basé sur l'IA pour détecter les images similaires, avec lequel vous pouvez nettoyer votre bibliothèque. Disponible dans Paramètres -> Sauvegarde -> Libérer de l'espace",
|
||||
"cLTitle2": "Améliorations de la diffusion vidéo",
|
||||
"cLDesc2": "Vous pouvez maintenant déclencher manuellement la génération de flux pour les vidéos directement depuis l'application. Nous avons également ajouté un nouvel écran de paramètres de diffusion vidéo qui vous montrera quel pourcentage de vos vidéos ont été traitées pour la diffusion",
|
||||
"cLTitle3": "Améliorations des performances",
|
||||
"cLDesc3": "Plusieurs améliorations internes, incluant une meilleure utilisation du cache et une expérience de défilement plus fluide"
|
||||
}
|
||||
@@ -1745,5 +1745,11 @@
|
||||
"birthdayNotifications": "Notifiche dei compleanni",
|
||||
"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"
|
||||
"birthdays": "Compleanni",
|
||||
"cLTitle1": "Immagini simili",
|
||||
"cLDesc1": "Stiamo introducendo un nuovo sistema basato su ML per rilevare immagini simili, con il quale puoi pulire la tua libreria. Disponibile in Impostazioni -> Backup -> Libera spazio",
|
||||
"cLTitle2": "Miglioramenti streaming video",
|
||||
"cLDesc2": "Ora puoi attivare manualmente la generazione di stream per i video direttamente dall'app. Abbiamo anche aggiunto una nuova schermata delle impostazioni di streaming video che ti mostrerà quale percentuale dei tuoi video è stata elaborata per lo streaming",
|
||||
"cLTitle3": "Miglioramenti delle prestazioni",
|
||||
"cLDesc3": "Multipli miglioramenti interni, incluso un miglior utilizzo della cache e un'esperienza di scorrimento più fluida"
|
||||
}
|
||||
@@ -1665,5 +1665,11 @@
|
||||
"moon": "月明かりの中",
|
||||
"onTheRoad": "再び道で",
|
||||
"food": "料理を楽しむ",
|
||||
"pets": "毛むくじゃらな仲間たち"
|
||||
"pets": "毛むくじゃらな仲間たち",
|
||||
"cLTitle1": "類似画像",
|
||||
"cLDesc1": "類似画像を検出する新しいML基盤システムを導入し、ライブラリをクリーンアップできます。設定 -> バックアップ -> 容量を空ける で利用可能",
|
||||
"cLTitle2": "動画ストリーミングの強化",
|
||||
"cLDesc2": "アプリから直接、動画のストリーム生成を手動でトリガーできるようになりました。また、動画のうち何パーセントがストリーミング用に処理されたかを表示する新しい動画ストリーミング設定画面も追加しました",
|
||||
"cLTitle3": "パフォーマンスの改善",
|
||||
"cLDesc3": "より良いキャッシュ使用とよりスムーズなスクロール体験を含む、複数の内部改善"
|
||||
}
|
||||
@@ -1776,14 +1776,6 @@
|
||||
"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ę",
|
||||
@@ -1830,4 +1822,4 @@
|
||||
"size": "Dydis",
|
||||
"similarity": "Panašumas",
|
||||
"processingLocally": "Apdorojama vietoje"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1772,5 +1772,11 @@
|
||||
"thePersonWillNotBeDisplayed": "De persoon wordt niet meer getoond in de personen sectie. Foto's blijven ongemoeid.",
|
||||
"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"
|
||||
"yesIgnore": "Ja, negeer",
|
||||
"cLTitle1": "Vergelijkbare afbeeldingen",
|
||||
"cLDesc1": "We introduceren een nieuw ML-gebaseerd systeem om vergelijkbare afbeeldingen te detecteren, waarmee je je bibliotheek kunt opschonen. Beschikbaar in Instellingen -> Backup -> Ruimte vrijmaken",
|
||||
"cLTitle2": "Video streaming verbeteringen",
|
||||
"cLDesc2": "Je kunt nu handmatig stream generatie voor video's activeren direct vanuit de app. We hebben ook een nieuw video streaming instellingenscherm toegevoegd dat toont welk percentage van je video's is verwerkt voor streaming",
|
||||
"cLTitle3": "Prestatieverbeteringen",
|
||||
"cLDesc3": "Meerdere verbeteringen onder de motorkap, inclusief beter cache gebruik en een vloeiendere scroll ervaring"
|
||||
}
|
||||
@@ -1736,5 +1736,11 @@
|
||||
"albumsWidgetDesc": "Velg albumene du ønsker å se på din hjemskjerm.",
|
||||
"memoriesWidgetDesc": "Velg typen minner du ønsker å se på din hjemskjerm.",
|
||||
"smartMemories": "Smarte minner",
|
||||
"pastYearsMemories": "Tidligere års minner"
|
||||
"pastYearsMemories": "Tidligere års minner",
|
||||
"cLTitle1": "Lignende bilder",
|
||||
"cLDesc1": "Vi introduserer et nytt ML-basert system for å oppdage lignende bilder, som du kan bruke til å rydde opp i biblioteket ditt. Tilgjengelig i Innstillinger -> Sikkerhetskopi -> Frigjør plass",
|
||||
"cLTitle2": "Video streaming forbedringer",
|
||||
"cLDesc2": "Du kan nå manuelt utløse stream generering for videoer direkte fra appen. Vi har også lagt til en ny video streaming innstillinger skjerm som viser deg hvor mange prosent av videoene dine som er behandlet for streaming",
|
||||
"cLTitle3": "Ytelsesforbedringer",
|
||||
"cLDesc3": "Flere forbedringer under panseret, inkludert bedre cache bruk og en jevnere rullingsopplevelse"
|
||||
}
|
||||
@@ -1776,14 +1776,6 @@
|
||||
"same": "Identyczne",
|
||||
"different": "Inne",
|
||||
"sameperson": "Ta sama osoba?",
|
||||
"cLTitle1": "Zaawansowany Edytor Obrazów",
|
||||
"cLDesc1": "Wydajemy nowy i zaawansowany edytor obrazów, który dodaje więcej klatek przycinania, filtry dla szybkich edycji, precyzyjne opcje dostrajania, w tym nasycenie, kontrast, jasność, temperatura i wiele więcej. Nowy edytor zawiera również możliwość rysowania zdjęć i dodawania emotikonów jako naklejki.",
|
||||
"cLTitle2": "Inteligentne Albumy",
|
||||
"cLDesc2": "Teraz możesz automatycznie dodawać zdjęcia wybranych osób do dowolnego albumu. Po prostu przejdź do albumu i wybierz \"automatycznie dodaj osoby\" z menu przepełnienia. Jeśli używane razem z udostępnionym albumem, możesz udostępniać zdjęcia bez żadnych kliknięć.",
|
||||
"cLTitle3": "Ulepszona Galeria",
|
||||
"cLDesc3": "Dodaliśmy możliwość grupowania Twojej galerii po tygodniach, miesiącach i latach. Możesz teraz spersonalizować swoją galerię, aby dokładnie wyglądać w ten sposób z nowymi opcjami grupowania, wraz z niestandardowymi siatkami",
|
||||
"cLTitle4": "Szybsze Przewijanie",
|
||||
"cLDesc4": "Wraz z kilkoma ulepszeniami w celu poprawy doświadczenia galerii, przeprojektowaliśmy również pasek przewijania, aby pokazywać znaczniki, umożliwiając szybki skok po osi czasu.",
|
||||
"indexingPausedStatusDescription": "Indeksowanie zostało wstrzymane. Zostanie automatycznie wznowione, gdy urządzenie będzie gotowe. Urządzenie uznaje się za gotowe, gdy poziom baterii, stan jej zdrowia oraz status termiczny znajdują się w bezpiecznym zakresie.",
|
||||
"thisWeek": "Ten tydzień",
|
||||
"lastWeek": "Zeszły tydzień",
|
||||
@@ -1827,5 +1819,11 @@
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"cLTitle1": "Podobne obrazy",
|
||||
"cLDesc1": "Wprowadzamy nowy system oparty na ML do wykrywania podobnych obrazów, za pomocą którego możesz posprzątać swoją bibliotekę. Dostępne w Ustawienia->Kopia zapasowa->Zwolnij miejsce",
|
||||
"cLTitle2": "Ulepszenia streamingu wideo",
|
||||
"cLDesc2": "Możesz teraz ręcznie wyzwolić generowanie strumienia dla filmów bezpośrednio z aplikacji. Dodaliśmy również nowy ekran ustawień streamingu wideo, który pokaże ci, jaki procent twoich filmów zostało przetworzonych do streamingu",
|
||||
"cLTitle3": "Ulepszenia wydajności",
|
||||
"cLDesc3": "Liczne ulepszenia pod maską, w tym lepsze wykorzystanie pamięci podręcznej i płynniejsze przewijanie"
|
||||
}
|
||||
@@ -1776,14 +1776,6 @@
|
||||
"same": "Igual",
|
||||
"different": "Diferente",
|
||||
"sameperson": "Mesma pessoa?",
|
||||
"cLTitle1": "Editor de Imagens Avançado",
|
||||
"cLDesc1": "Estamos lançando um novo editor de fotos avançado que adiciona mais quadros de recorte, predefinições de filtro para edições rápidas, ajustes para afinação incluindo saturação, contraste, brilho, temperatura e mais. O novo editor também incluí a habilidade de desenhar em suas fotos e adicionar emojis como figurinhas.",
|
||||
"cLTitle2": "Álbuns Inteligentes",
|
||||
"cLDesc2": "Você agora pode adicionar automaticamente fotos de pessoas selecionadas para qualquer álbum. É só ir ao álbum, selecionar \"adicionar pessoa auto.\" no menu avançado. Se usado junto ao álbum compartilhado, você pode compartilhar fotos sem maior esforço.",
|
||||
"cLTitle3": "Galeria Aprimorada",
|
||||
"cLDesc3": "Adicionamos a habilidade de agrupar sua galeria por semanas, meses, e anos. Você pode personalizar sua galeria para parecer exatamente a maneira que desejar usando as novas opções de agrupamento, junto às grades personalizadas",
|
||||
"cLTitle4": "Arrastar Rápido",
|
||||
"cLDesc4": "Junto ao tanto de melhorias salva-vidas para melhorar a experiência de arraste na galeria, também redesenhamos a barra de deslize para exibir marcadores, permitindo você pular a timeline rapidamente.",
|
||||
"indexingPausedStatusDescription": "A indexação foi pausada. Ela retomará automaticamente quando o dispositivo estiver pronto. O dispositivo é considerado pronto quando o nível de bateria, saúde da bateria, e estado térmico estejam num alcance saudável.",
|
||||
"thisWeek": "Esta semana",
|
||||
"lastWeek": "Semana passada",
|
||||
@@ -1827,5 +1819,11 @@
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"cLTitle1": "Imagens similares",
|
||||
"cLDesc1": "Estamos introduzindo um novo sistema baseado em ML para detectar imagens similares, com o qual você pode limpar sua biblioteca. Disponível em Configurações -> Backup -> Liberar espaço",
|
||||
"cLTitle2": "Melhorias do streaming de vídeo",
|
||||
"cLDesc2": "Agora você pode acionar manualmente a geração de stream para vídeos diretamente do aplicativo. Também adicionamos uma nova tela de configurações de streaming de vídeo que mostrará qual porcentagem dos seus vídeos foram processados para streaming",
|
||||
"cLTitle3": "Melhorias de desempenho",
|
||||
"cLDesc3": "Múltiplas melhorias internas, incluindo melhor uso de cache e uma experiência de rolagem mais suave"
|
||||
}
|
||||
|
||||
@@ -1776,14 +1776,6 @@
|
||||
"same": "Igual",
|
||||
"different": "Diferente",
|
||||
"sameperson": "A mesma pessoa?",
|
||||
"cLTitle1": "Editor de Imagens Avançado",
|
||||
"cLDesc1": "Estamos a lançar um novo editor avançado que adiciona mais ecrãs de recorte, predefinições de filtro para edições ágeis, ajustes de afinação incluindo saturação, contraste, brilho, temperatura e mais além. O novo editor também será possível desenhar nas suas fotos e adicionar emojis como autocolantes.",
|
||||
"cLTitle2": "Álbuns Inteligentes",
|
||||
"cLDesc2": "Agora pode automaticamente adicionar fotos de pessoas selecionadas para qualquer álbum. É só ir até o álbum, e clicar \"auto adicionar pessoa\" no menu expandido. Se usado com o álbum, pode partilhar fotos sem esforço.",
|
||||
"cLTitle3": "Fototeca Improvisada",
|
||||
"cLDesc3": "Adicionamos o agrupamento à sua fototeca, com filtro de semanas, meses, e anos. Pode personalizar a sua fototeca para parecer como desejar ao usar as novas definições de agrupamento, junto às grades personalizadas",
|
||||
"cLTitle4": "Arraste Ágil",
|
||||
"cLDesc4": "Junto às improvisações salva-vidas para melhorar a experiência de arraste na fototeca, também redesenhamos o slider para mostrar marcadores, permitindo você pular a linha do tempo mais fácil.",
|
||||
"indexingPausedStatusDescription": "A indexação foi interrompida. Ele será retomado se o dispositivo estiver pronto. O dispositivo é considerado pronto se o nível de bateria, saúde da bateria, e estado térmico esteja num estado saudável.",
|
||||
"thisWeek": "Esta semana",
|
||||
"lastWeek": "Semana passada",
|
||||
@@ -1827,5 +1819,11 @@
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"cLTitle1": "Imagens similares",
|
||||
"cLDesc1": "Estamos a introduzir um novo sistema baseado em ML para detectar imagens similares, com o qual pode limpar a sua biblioteca. Disponível em Definições -> Cópia de segurança -> Libertar espaço",
|
||||
"cLTitle2": "Melhorias do streaming de vídeo",
|
||||
"cLDesc2": "Agora pode accionar manualmente a geração de stream para vídeos directamente da aplicação. Também adicionámos um novo ecrã de definições de streaming de vídeo que mostrará que percentagem dos seus vídeos foram processados para streaming",
|
||||
"cLTitle3": "Melhorias de desempenho",
|
||||
"cLDesc3": "Múltiplas melhorias internas, incluindo melhor uso de cache e uma experiência de deslocação mais suave"
|
||||
}
|
||||
|
||||
@@ -1521,5 +1521,11 @@
|
||||
"joinAlbum": "Alăturați-vă albumului",
|
||||
"joinAlbumSubtext": "pentru a vedea și a adăuga fotografii",
|
||||
"joinAlbumSubtextViewer": "pentru a adăuga la albumele distribuite",
|
||||
"join": "Alăturare"
|
||||
"join": "Alăturare",
|
||||
"cLTitle1": "Imagini similare",
|
||||
"cLDesc1": "Introducem un nou sistem bazat pe ML pentru detectarea imaginilor similare, cu care vă puteți curăța biblioteca. Disponibil în Setări->Backup->Eliberați Spațiu",
|
||||
"cLTitle2": "Îmbunătățiri streaming video",
|
||||
"cLDesc2": "Acum puteți declanșa manual generarea fluxului pentru videoclipuri direct din aplicație. Am adăugat, de asemenea, un nou ecran de setări pentru streaming video care vă va arăta ce procent din videoclipurile dvs. au fost procesate pentru streaming",
|
||||
"cLTitle3": "Îmbunătățiri de Performanță",
|
||||
"cLDesc3": "Multiple îmbunătățiri în fundal, inclusiv o utilizare mai bună a cache-ului și o experiență de defilare mai fluidă"
|
||||
}
|
||||
@@ -1785,5 +1785,11 @@
|
||||
"analysis": "Анализ",
|
||||
"day": "День",
|
||||
"filter": "Фильтр",
|
||||
"font": "Шрифт"
|
||||
"font": "Шрифт",
|
||||
"cLTitle1": "Похожие изображения",
|
||||
"cLDesc1": "Мы внедряем новую систему на основе ML для обнаружения похожих изображений, с помощью которой вы можете очистить свою библиотеку. Доступно в Настройки->Резервная копия->Освободить место",
|
||||
"cLTitle2": "Улучшения видео стриминга",
|
||||
"cLDesc2": "Теперь вы можете вручную запустить генерацию потока для видео прямо из приложения. Мы также добавили новый экран настроек видео стриминга, который покажет вам, какой процент ваших видео был обработан для стриминга",
|
||||
"cLTitle3": "Улучшения производительности",
|
||||
"cLDesc3": "Множественные улучшения под капотом, включая лучшее использование кэша и более плавную прокрутку"
|
||||
}
|
||||
@@ -1776,5 +1776,11 @@
|
||||
"same": "Aynı",
|
||||
"different": "Farklı",
|
||||
"sameperson": "Aynı kişi mi?",
|
||||
"indexingPausedStatusDescription": "Dizin oluşturma duraklatıldı. Cihaz hazır olduğunda otomatik olarak devam edecektir. Cihaz, pil seviyesi, pil sağlığı ve termal durumu sağlıklı bir aralıkta olduğunda hazır kabul edilir."
|
||||
"indexingPausedStatusDescription": "Dizin oluşturma duraklatıldı. Cihaz hazır olduğunda otomatik olarak devam edecektir. Cihaz, pil seviyesi, pil sağlığı ve termal durumu sağlıklı bir aralıkta olduğunda hazır kabul edilir.",
|
||||
"cLTitle1": "Benzer görüntüler",
|
||||
"cLDesc1": "Benzer görüntüleri tespit etmek için yeni bir ML tabanlı sistem tanıtıyoruz, bununla kütüphanenizi temizleyebilirsiniz. Ayarlar -> Yedekleme -> Alan boşalt kısmından ulaşabilirsiniz",
|
||||
"cLTitle2": "Video akış geliştirmeleri",
|
||||
"cLDesc2": "Artık doğrudan uygulamadan videolar için akış oluşturmayı manuel olarak tetikleyebilirsiniz. Ayrıca videolarınızın yüzde kaçının akış için işlendiğini gösteren yeni bir video akış ayarları ekranı da ekledik",
|
||||
"cLTitle3": "Performans İyileştirmeleri",
|
||||
"cLDesc3": "Daha iyi önbellek kullanımı ve daha pürüzsüz kaydırma deneyimi dahil olmak üzere perde arkasında birçok iyileştirme"
|
||||
}
|
||||
@@ -1509,5 +1509,11 @@
|
||||
},
|
||||
"legacyInvite": "{email} запросив вас стати довіреною особою",
|
||||
"authToManageLegacy": "Авторизуйтесь, щоби керувати довіреними контактами",
|
||||
"useDifferentPlayerInfo": "Виникли проблеми з відтворенням цього відео? Натисніть і утримуйте тут, щоб спробувати інший плеєр."
|
||||
"useDifferentPlayerInfo": "Виникли проблеми з відтворенням цього відео? Натисніть і утримуйте тут, щоб спробувати інший плеєр.",
|
||||
"cLTitle1": "Схожі зображення",
|
||||
"cLDesc1": "Ми впроваджуємо нову систему на основі ML для виявлення схожих зображень, за допомогою якої ви можете очистити свою бібліотеку. Доступно в Налаштування->Резервна копія->Звільнити місце",
|
||||
"cLTitle2": "Покращення відео стрімінгу",
|
||||
"cLDesc2": "Тепер ви можете вручну запустити генерацію потоку для відео прямо з додатку. Ми також додали новий екран налаштувань відео стрімінгу, який покаже вам, який відсоток ваших відео було оброблено для стрімінгу",
|
||||
"cLTitle3": "Покращення продуктивності",
|
||||
"cLDesc3": "Численні покращення під капотом, включаючи краще використання кешу та більш плавну прокрутку"
|
||||
}
|
||||
@@ -1776,14 +1776,6 @@
|
||||
"same": "Chính xác",
|
||||
"different": "Khác",
|
||||
"sameperson": "Cùng một người?",
|
||||
"cLTitle1": "Trình chỉnh sửa ảnh nâng cao",
|
||||
"cLDesc1": "Chúng tôi phát hành một trình chỉnh sửa ảnh tân tiến, bổ sung thêm cắt ảnh, bộ lọc có sẵn để chỉnh sửa nhanh, các tùy chọn tinh chỉnh bao gồm độ bão hòa, độ tương phản, độ sáng, độ ấm và nhiều hơn nữa. Trình chỉnh sửa mới cũng bao gồm khả năng vẽ lên ảnh và thêm emoji dưới dạng nhãn dán.",
|
||||
"cLTitle2": "Album thông minh",
|
||||
"cLDesc2": "Giờ đây, bạn có thể tự động thêm ảnh của những người đã chọn vào bất kỳ album nào. Chỉ cần mở album và chọn \"Tự động thêm người\" trong menu. Nếu sử dụng cùng với album chia sẻ, bạn có thể chia sẻ ảnh mà không cần tốn công.",
|
||||
"cLTitle3": "Cải tiến Thư viện ảnh",
|
||||
"cLDesc3": "Chúng tôi bổ sung tính năng phân nhóm thư viện ảnh theo tuần, tháng và năm. Giờ đây, bạn có thể tùy chỉnh thư viện ảnh theo đúng ý muốn với các tùy chọn mới này, cùng với các lưới tùy chỉnh.",
|
||||
"cLTitle4": "Cuộn nhanh hơn",
|
||||
"cLDesc4": "Cùng với một loạt cải tiến ngầm nhằm nâng cao trải nghiệm cuộn thư viện, chúng tôi cũng đã thiết kế lại thanh cuộn để hiển thị các điểm đánh dấu, cho phép bạn nhanh chóng nhảy cóc trên dòng thời gian.",
|
||||
"indexingPausedStatusDescription": "Lập chỉ mục bị tạm dừng. Nó sẽ tự động tiếp tục khi thiết bị đã sẵn sàng. Thiết bị được coi là sẵn sàng khi mức pin, tình trạng pin và trạng thái nhiệt độ nằm trong phạm vi tốt.",
|
||||
"thisWeek": "Tuần này",
|
||||
"lastWeek": "Tuần trước",
|
||||
@@ -1939,5 +1931,11 @@
|
||||
"related": "Có liên quan",
|
||||
"hoorayyyy": "Hoorayyyy!",
|
||||
"nothingToTidyUpHere": "Ở đây đã ngon lành rồi",
|
||||
"deletingDash": "Đang xóa - "
|
||||
}
|
||||
"deletingDash": "Đang xóa - ",
|
||||
"cLTitle1": "Hình ảnh tương tự",
|
||||
"cLDesc1": "Chúng tôi đang giới thiệu một hệ thống dựa trên ML mới để phát hiện hình ảnh tương tự, bạn có thể dùng để dọn dẹp thư viện của mình. Có sẵn trong Cài đặt -> Sao lưu -> Giải phóng dung lượng",
|
||||
"cLTitle2": "Cải thiện streaming video",
|
||||
"cLDesc2": "Bây giờ bạn có thể kích hoạt tạo luồng cho video trực tiếp từ ứng dụng. Chúng tôi cũng đã thêm màn hình cài đặt phát trực tuyến video mới sẽ cho bạn biết bao nhiêu phần trăm video của bạn đã được xử lý để phát trực tuyến",
|
||||
"cLTitle3": "Cải Thiện Hiệu Suất",
|
||||
"cLDesc3": "Nhiều cải thiện bên trong, bao gồm sử dụng bộ nhớ đệm tốt hơn và trải nghiệm cuộn mượt mà hơn"
|
||||
}
|
||||
|
||||
@@ -1776,14 +1776,6 @@
|
||||
"same": "相同",
|
||||
"different": "不同",
|
||||
"sameperson": "是同一个人?",
|
||||
"cLTitle1": "高级图像编辑器",
|
||||
"cLDesc1": "我们正在发布一款全新且高级的图像编辑器,新增更多裁剪框架、快速编辑的滤镜预设,以及包括饱和度、对比度、亮度、色温等在内的精细调整选项。新的编辑器还支持在照片上绘制和添加表情符号作为贴纸。",
|
||||
"cLTitle2": "智能相册",
|
||||
"cLDesc2": "您现在可以将所选人物的照片自动添加到任何相册。只需进入相册,从溢出菜单中选择“自动添加人物”。如果与共享相册一起使用,您可以零点击分享照片。",
|
||||
"cLTitle3": "改进的相册",
|
||||
"cLDesc3": "我们新增了按周、月、年对图库进行分组的功能。您现在可以通过这些新的分组选项以及自定义网格,定制图库的外观,完全按照您的喜好进行设置",
|
||||
"cLTitle4": "更快滚动",
|
||||
"cLDesc4": "除了多项后台改进以提升图库滚动体验外,我们还重新设计了滚动条,添加了标记功能,让您可以快速跳转到时间轴上的不同位置。",
|
||||
"indexingPausedStatusDescription": "索引已暂停。待设备准备就绪后,索引将自动恢复。当设备的电池电量、电池健康度和温度状态处于健康范围内时,设备即被视为准备就绪。",
|
||||
"thisWeek": "本周",
|
||||
"lastWeek": "上周",
|
||||
@@ -1933,5 +1925,11 @@
|
||||
"nothingHereTryAnotherFilter": "此处无内容,请尝试其他过滤器!👀",
|
||||
"related": "相关",
|
||||
"hoorayyyy": "耶~~!",
|
||||
"nothingToTidyUpHere": "这里没什么可清理的"
|
||||
}
|
||||
"nothingToTidyUpHere": "这里没什么可清理的",
|
||||
"cLTitle1": "相似图像",
|
||||
"cLDesc1": "我们正在推出一个基于机器学习的新系统来检测相似图像,您可以用它来清理您的图库。在 设置 -> 备份 -> 释放空间 中可用",
|
||||
"cLTitle2": "视频流媒体增强",
|
||||
"cLDesc2": "您现在可以直接从应用程序手动触发视频的流生成。我们还添加了一个新的视频流设置屏幕,它将显示您的视频中有百分之几已被处理用于流媒体播放",
|
||||
"cLTitle3": "性能改进",
|
||||
"cLDesc3": "多个底层改进,包括更好的缓存使用和更流畅的滚动体验"
|
||||
}
|
||||
|
||||
@@ -13,7 +13,8 @@ class Collection {
|
||||
final User owner;
|
||||
final String encryptedKey;
|
||||
final String? keyDecryptionNonce;
|
||||
@Deprecated("Use collectionName instead")
|
||||
|
||||
/// WARNING: use collectionName instead of name! Name is deprecated but can't be removed because of old accounts.
|
||||
String? name;
|
||||
|
||||
// encryptedName & nameDecryptionNonce will be null for collections
|
||||
|
||||
@@ -14,6 +14,7 @@ import "package:photos/models/collection/collection_items.dart";
|
||||
import "package:photos/models/search/search_result.dart";
|
||||
import "package:photos/models/typedefs.dart";
|
||||
import "package:photos/services/collections_service.dart";
|
||||
import "package:photos/services/machine_learning/face_ml/face_filtering/face_filtering_constants.dart";
|
||||
import "package:photos/services/search_service.dart";
|
||||
import "package:photos/ui/viewer/gallery/collection_page.dart";
|
||||
import "package:photos/ui/viewer/location/add_location_sheet.dart";
|
||||
@@ -220,7 +221,12 @@ extension SectionTypeExtensions on SectionType {
|
||||
}) {
|
||||
switch (this) {
|
||||
case SectionType.face:
|
||||
return SearchService.instance.getAllFace(limit);
|
||||
return SearchService.instance.getAllFace(
|
||||
limit,
|
||||
minClusterSize: limit == null
|
||||
? kMinimumClusterSizeAllFaces
|
||||
: kMinimumClusterSizeSearchResult,
|
||||
);
|
||||
case SectionType.magic:
|
||||
return SearchService.instance.getMagicSectionResults(context!);
|
||||
case SectionType.location:
|
||||
|
||||
@@ -110,7 +110,7 @@ class DateParseService {
|
||||
|
||||
result = _parseStructuredFormats(lowerInput);
|
||||
if (!result.isEmpty) return result;
|
||||
|
||||
|
||||
final normalized = _normalizeDateString(lowerInput);
|
||||
result = _parseTokenizedDate(normalized);
|
||||
|
||||
@@ -203,7 +203,7 @@ class DateParseService {
|
||||
}
|
||||
return PartialDate.empty;
|
||||
}
|
||||
|
||||
|
||||
match = _standardFormatRegex.firstMatch(cleanInput);
|
||||
if (match != null) {
|
||||
final p1 = int.parse(match.group(1)!);
|
||||
|
||||
@@ -49,32 +49,47 @@ class ComputeController {
|
||||
|
||||
ComputeController() {
|
||||
_logger.info('ComputeController constructor');
|
||||
init();
|
||||
_logger.info('init done ');
|
||||
}
|
||||
|
||||
// Directly assign the values + Attach listener for compute controller
|
||||
Future<void> init() async {
|
||||
// Interaction Timer
|
||||
_startInteractionTimer(kDefaultInteractionTimeout);
|
||||
|
||||
// Thermal related
|
||||
_onThermalStateUpdate(await _thermal.thermalStatus);
|
||||
_thermal.onThermalStatusChanged.listen((ThermalStatus thermalState) {
|
||||
_onThermalStateUpdate(thermalState);
|
||||
});
|
||||
|
||||
// Battery State
|
||||
if (Platform.isIOS) {
|
||||
if (kDebugMode) {
|
||||
_logger.info(
|
||||
_logger.fine(
|
||||
"iOS battery info stream is not available in simulator, disabling in debug mode",
|
||||
);
|
||||
// if you need to test on physical device, uncomment this check
|
||||
return;
|
||||
} else {
|
||||
// Update Battery state for iOS
|
||||
_oniOSBatteryStateUpdate(await BatteryInfoPlugin().iosBatteryInfo);
|
||||
BatteryInfoPlugin()
|
||||
.iosBatteryInfoStream
|
||||
.listen((IosBatteryInfo? batteryInfo) {
|
||||
_oniOSBatteryStateUpdate(batteryInfo);
|
||||
});
|
||||
}
|
||||
BatteryInfoPlugin()
|
||||
.iosBatteryInfoStream
|
||||
.listen((IosBatteryInfo? batteryInfo) {
|
||||
_oniOSBatteryStateUpdate(batteryInfo);
|
||||
});
|
||||
}
|
||||
if (Platform.isAndroid) {
|
||||
} else if (Platform.isAndroid) {
|
||||
// Update Battery state for Android
|
||||
_onAndroidBatteryStateUpdate(
|
||||
await BatteryInfoPlugin().androidBatteryInfo,
|
||||
);
|
||||
BatteryInfoPlugin()
|
||||
.androidBatteryInfoStream
|
||||
.listen((AndroidBatteryInfo? batteryInfo) {
|
||||
_onAndroidBatteryStateUpdate(batteryInfo);
|
||||
});
|
||||
}
|
||||
_thermal.onThermalStatusChanged.listen((ThermalStatus thermalState) {
|
||||
_onThermalStateUpdate(thermalState);
|
||||
});
|
||||
_logger.info('init done ');
|
||||
}
|
||||
|
||||
bool requestCompute({
|
||||
|
||||
@@ -46,7 +46,6 @@ import 'package:photos/services/collections_service.dart';
|
||||
import "package:photos/services/date_parse_service.dart";
|
||||
import "package:photos/services/filter/db_filters.dart";
|
||||
import "package:photos/services/location_service.dart";
|
||||
import "package:photos/services/machine_learning/face_ml/face_filtering/face_filtering_constants.dart";
|
||||
import "package:photos/services/machine_learning/face_ml/person/person_service.dart";
|
||||
import 'package:photos/services/machine_learning/semantic_search/semantic_search_service.dart';
|
||||
import "package:photos/services/memories_cache_service.dart";
|
||||
@@ -725,7 +724,7 @@ class SearchService {
|
||||
|
||||
Future<List<GenericSearchResult>> getAllFace(
|
||||
int? limit, {
|
||||
int minClusterSize = kMinimumClusterSizeSearchResult,
|
||||
required int minClusterSize,
|
||||
}) async {
|
||||
try {
|
||||
debugPrint("getting faces");
|
||||
@@ -894,13 +893,7 @@ class SearchService {
|
||||
),
|
||||
);
|
||||
}
|
||||
if (facesResult.isEmpty) {
|
||||
if (kMinimumClusterSizeAllFaces < minClusterSize) {
|
||||
return getAllFace(limit, minClusterSize: kMinimumClusterSizeAllFaces);
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
if (facesResult.isEmpty) return [];
|
||||
if (limit != null) {
|
||||
return facesResult.sublist(0, min(limit, facesResult.length));
|
||||
} else {
|
||||
|
||||
@@ -14,7 +14,7 @@ import 'package:url_launcher/url_launcher_string.dart';
|
||||
class UpdateService {
|
||||
static const kUpdateAvailableShownTimeKey = "update_available_shown_time_key";
|
||||
static const changeLogVersionKey = "update_change_log_key";
|
||||
static const currentChangeLogVersion = 31;
|
||||
static const currentChangeLogVersion = 36;
|
||||
|
||||
LatestVersionInfo? _latestVersion;
|
||||
final _logger = Logger("UpdateService");
|
||||
|
||||
@@ -157,7 +157,25 @@ class VideoPreviewService {
|
||||
|
||||
bool isCurrentlyProcessing(int? uploadedFileID) {
|
||||
if (uploadedFileID == null) return false;
|
||||
return uploadingFileId == uploadedFileID;
|
||||
|
||||
// Also check if file is in queue or other processing states
|
||||
final item = _items[uploadedFileID];
|
||||
if (item != null) {
|
||||
switch (item.status) {
|
||||
case PreviewItemStatus.inQueue:
|
||||
case PreviewItemStatus.compressing:
|
||||
case PreviewItemStatus.uploading:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
PreviewItemStatus? getProcessingStatus(int uploadedFileID) {
|
||||
return _items[uploadedFileID]?.status;
|
||||
}
|
||||
|
||||
Future<bool> _isRecreateOperation(EnteFile file) async {
|
||||
@@ -256,15 +274,20 @@ class VideoPreviewService {
|
||||
|
||||
Future<void> chunkAndUploadVideo(
|
||||
BuildContext? ctx,
|
||||
EnteFile enteFile, [
|
||||
EnteFile enteFile, {
|
||||
/// Indicates this function is an continuation of a chunking thread
|
||||
bool continuation = false,
|
||||
// not used currently
|
||||
bool forceUpload = false,
|
||||
bool isManual = false,
|
||||
]) async {
|
||||
}) async {
|
||||
final bool isManual =
|
||||
await uploadLocksDB.isInStreamQueue(enteFile.uploadedFileID!);
|
||||
final canStream = _isPermissionGranted();
|
||||
if (!canStream) {
|
||||
_logger.info(
|
||||
"Pause preview due to disabledSteaming($isVideoStreamingEnabled) or computeController permission) - isManual: $isManual",
|
||||
);
|
||||
computeController.releaseCompute(stream: true);
|
||||
if (isVideoStreamingEnabled) _logger.info("No permission to run compute");
|
||||
clearQueue();
|
||||
return;
|
||||
@@ -311,7 +334,7 @@ class VideoPreviewService {
|
||||
}
|
||||
|
||||
// check if there is already a preview in processing
|
||||
if (uploadingFileId >= 0) {
|
||||
if (!continuation && uploadingFileId >= 0) {
|
||||
if (uploadingFileId == enteFile.uploadedFileID) return;
|
||||
|
||||
_items[enteFile.uploadedFileID!] = PreviewItem(
|
||||
@@ -562,21 +585,26 @@ class VideoPreviewService {
|
||||
_removeFile(enteFile);
|
||||
_removeFromLocks(enteFile).ignore();
|
||||
}
|
||||
// reset uploading status if this was getting processed
|
||||
if (uploadingFileId == enteFile.uploadedFileID!) {
|
||||
uploadingFileId = -1;
|
||||
}
|
||||
_logger.info(
|
||||
"[chunk] Processing ${_items.length} items for streaming, $error",
|
||||
);
|
||||
// process next file
|
||||
if (fileQueue.isNotEmpty) {
|
||||
// process next file
|
||||
_logger.info(
|
||||
"[chunk] Processing ${_items.length} items for streaming, $error",
|
||||
);
|
||||
final entry = fileQueue.entries.first;
|
||||
final file = entry.value;
|
||||
fileQueue.remove(entry.key);
|
||||
await chunkAndUploadVideo(ctx, file);
|
||||
await chunkAndUploadVideo(
|
||||
ctx,
|
||||
file,
|
||||
continuation: true,
|
||||
);
|
||||
} else {
|
||||
_logger.info(
|
||||
"[chunk] Nothing to process releasing compute, $error",
|
||||
);
|
||||
computeController.releaseCompute(stream: true);
|
||||
|
||||
uploadingFileId = -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -987,8 +1015,9 @@ class VideoPreviewService {
|
||||
}
|
||||
|
||||
// generate stream for all files after cutoff date
|
||||
Future<void> _putFilesForPreviewCreation() async {
|
||||
if (!isVideoStreamingEnabled || !await canUseHighBandwidth()) return;
|
||||
// returns false if it fails to launch chuncking function
|
||||
Future<bool> _putFilesForPreviewCreation() async {
|
||||
if (!isVideoStreamingEnabled || !await canUseHighBandwidth()) return false;
|
||||
|
||||
Map<int, String> failureFiles = {};
|
||||
Map<int, String> manualQueueFiles = {};
|
||||
@@ -1124,7 +1153,7 @@ class VideoPreviewService {
|
||||
final totalFiles = fileQueue.length;
|
||||
if (totalFiles == 0) {
|
||||
_logger.info("[init] No preview to cache");
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
_logger.info(
|
||||
@@ -1136,6 +1165,7 @@ class VideoPreviewService {
|
||||
final file = entry.value;
|
||||
fileQueue.remove(entry.key);
|
||||
chunkAndUploadVideo(null, file).ignore();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool _allowStream() {
|
||||
@@ -1152,9 +1182,11 @@ class VideoPreviewService {
|
||||
);
|
||||
}
|
||||
|
||||
/// To check if it's enabled, device is healthy and running streaming
|
||||
bool _isPermissionGranted() {
|
||||
return isVideoStreamingEnabled &&
|
||||
computeController.computeState == ComputeRunState.generatingStream;
|
||||
computeController.computeState == ComputeRunState.generatingStream &&
|
||||
computeController.isDeviceHealthy;
|
||||
}
|
||||
|
||||
void queueFiles({
|
||||
@@ -1169,7 +1201,11 @@ class VideoPreviewService {
|
||||
if (!isStreamAllowed) return;
|
||||
|
||||
await _ensurePreviewIdsInitialized();
|
||||
await _putFilesForPreviewCreation();
|
||||
final result = await _putFilesForPreviewCreation();
|
||||
// Cannot proceed to stream generation, would have to release compute ASAP
|
||||
if (!result) {
|
||||
computeController.releaseCompute(stream: true);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -344,7 +344,8 @@ class _PasswordEntryPageState extends State<PasswordEntryPage> {
|
||||
const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
|
||||
child: Text(
|
||||
AppLocalizations.of(context).passwordStrength(
|
||||
passwordStrengthValue: passwordStrengthText,),
|
||||
passwordStrengthValue: passwordStrengthText,
|
||||
),
|
||||
style: TextStyle(
|
||||
color: passwordStrengthColor,
|
||||
),
|
||||
|
||||
@@ -276,12 +276,12 @@ class _CollectionActionSheetState extends State<CollectionActionSheet> {
|
||||
isDisabled: _selectedCollections.isEmpty,
|
||||
onTap: () async {
|
||||
if (widget.selectedPeople != null) {
|
||||
final ProgressDialog? dialog = createProgressDialog(
|
||||
final ProgressDialog dialog = createProgressDialog(
|
||||
context,
|
||||
AppLocalizations.of(context).uploadingFilesToAlbum,
|
||||
isDismissible: true,
|
||||
);
|
||||
await dialog?.show();
|
||||
await dialog.show();
|
||||
for (final collection in _selectedCollections) {
|
||||
try {
|
||||
await smartAlbumsService.addPeopleToSmartAlbum(
|
||||
@@ -297,7 +297,7 @@ class _CollectionActionSheetState extends State<CollectionActionSheet> {
|
||||
}
|
||||
}
|
||||
unawaited(smartAlbumsService.syncSmartAlbums());
|
||||
await dialog?.hide();
|
||||
await dialog.hide();
|
||||
return;
|
||||
}
|
||||
final CollectionActions collectionActions =
|
||||
|
||||
@@ -35,9 +35,8 @@ class TextInputWidget extends StatefulWidget {
|
||||
final bool popNavAfterSubmission;
|
||||
final bool shouldSurfaceExecutionStates;
|
||||
final TextCapitalization? textCapitalization;
|
||||
@Deprecated(
|
||||
"Do not use this widget for password input. Create a separate PasswordInputWidget. This widget is becoming bloated and hard to maintain, so will create a PasswordInputWidget and remove this field from this widget in future",
|
||||
)
|
||||
|
||||
/// WARNING: Do not use this widget for password input. Create a separate PasswordInputWidget. This widget is becoming bloated and hard to maintain, so will create a PasswordInputWidget and remove this field from this widget in future
|
||||
final bool isPasswordInput;
|
||||
|
||||
///Clear comes in the form of a suffix icon. It is unrelated to onCancel.
|
||||
|
||||
@@ -77,7 +77,7 @@ class _ChangeLogPageState extends State<ChangeLogPage> {
|
||||
ButtonWidget(
|
||||
buttonType: ButtonType.trailingIconSecondary,
|
||||
buttonSize: ButtonSize.large,
|
||||
labelText: AppLocalizations.of(context).rateTheApp,
|
||||
labelText: AppLocalizations.of(context).rateUs,
|
||||
icon: Icons.favorite_rounded,
|
||||
iconColor: enteColorScheme.primary500,
|
||||
onTap: () async {
|
||||
@@ -112,12 +112,7 @@ class _ChangeLogPageState extends State<ChangeLogPage> {
|
||||
context.l10n.cLTitle3,
|
||||
context.l10n.cLDesc3,
|
||||
),
|
||||
ChangeLogEntry(
|
||||
context.l10n.cLTitle4,
|
||||
context.l10n.cLDesc4,
|
||||
),
|
||||
]);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.only(left: 16),
|
||||
child: Scrollbar(
|
||||
|
||||
@@ -9,12 +9,20 @@ import 'package:photos/theme/ente_theme.dart';
|
||||
import 'package:photos/ui/components/captioned_text_widget.dart';
|
||||
import 'package:photos/ui/components/expandable_menu_item_widget.dart';
|
||||
import 'package:photos/ui/components/menu_item_widget/menu_item_widget.dart';
|
||||
import 'package:photos/ui/components/toggle_switch_widget.dart';
|
||||
import 'package:photos/ui/notification/toast.dart';
|
||||
import 'package:photos/ui/settings/common_settings.dart';
|
||||
import 'package:photos/ui/settings/debug/local_thumbnail_config_screen.dart';
|
||||
import 'package:photos/utils/navigation_util.dart';
|
||||
|
||||
class DebugSectionWidget extends StatelessWidget {
|
||||
class DebugSectionWidget extends StatefulWidget {
|
||||
const DebugSectionWidget({super.key});
|
||||
|
||||
@override
|
||||
State<DebugSectionWidget> createState() => _DebugSectionWidgetState();
|
||||
}
|
||||
|
||||
class _DebugSectionWidgetState extends State<DebugSectionWidget> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ExpandableMenuItemWidget(
|
||||
@@ -27,6 +35,43 @@ class DebugSectionWidget extends StatelessWidget {
|
||||
Widget _getSectionOptions(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
sectionOptionSpacing,
|
||||
MenuItemWidget(
|
||||
captionedTextWidget: const CaptionedTextWidget(
|
||||
title: "Show local ID over thumbnails",
|
||||
),
|
||||
pressedColor: getEnteColorScheme(context).fillFaint,
|
||||
trailingWidget: ToggleSwitchWidget(
|
||||
value: () => localSettings.showLocalIDOverThumbnails,
|
||||
onChanged: () async {
|
||||
await localSettings.setShowLocalIDOverThumbnails(
|
||||
!localSettings.showLocalIDOverThumbnails,
|
||||
);
|
||||
setState(() {});
|
||||
showShortToast(
|
||||
context,
|
||||
localSettings.showLocalIDOverThumbnails
|
||||
? "Local IDs will be shown. Restart app."
|
||||
: "Local IDs hidden. Restart app.",
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
sectionOptionSpacing,
|
||||
MenuItemWidget(
|
||||
captionedTextWidget: const CaptionedTextWidget(
|
||||
title: "Local thumbnail queue config",
|
||||
),
|
||||
pressedColor: getEnteColorScheme(context).fillFaint,
|
||||
trailingIcon: Icons.chevron_right_outlined,
|
||||
trailingIconIsMuted: true,
|
||||
onTap: () async {
|
||||
await routeToPage(
|
||||
context,
|
||||
const LocalThumbnailConfigScreen(),
|
||||
);
|
||||
},
|
||||
),
|
||||
sectionOptionSpacing,
|
||||
MenuItemWidget(
|
||||
captionedTextWidget: const CaptionedTextWidget(
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:photos/service_locator.dart';
|
||||
import 'package:photos/theme/ente_theme.dart';
|
||||
import 'package:photos/ui/components/title_bar_title_widget.dart';
|
||||
import 'package:photos/ui/notification/toast.dart';
|
||||
|
||||
class LocalThumbnailConfigScreen extends StatefulWidget {
|
||||
const LocalThumbnailConfigScreen({super.key});
|
||||
|
||||
@override
|
||||
State<LocalThumbnailConfigScreen> createState() =>
|
||||
_LocalThumbnailConfigScreenState();
|
||||
}
|
||||
|
||||
class _LocalThumbnailConfigScreenState
|
||||
extends State<LocalThumbnailConfigScreen> {
|
||||
static final Logger _logger = Logger("LocalThumbnailConfigScreen");
|
||||
|
||||
late TextEditingController _smallMaxConcurrentController;
|
||||
late TextEditingController _smallTimeoutController;
|
||||
late TextEditingController _smallMaxSizeController;
|
||||
late TextEditingController _largeMaxConcurrentController;
|
||||
late TextEditingController _largeTimeoutController;
|
||||
late TextEditingController _largeMaxSizeController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initControllers();
|
||||
}
|
||||
|
||||
void _initControllers() {
|
||||
_smallMaxConcurrentController = TextEditingController(
|
||||
text: localSettings.smallQueueMaxConcurrent.toString(),
|
||||
);
|
||||
_smallTimeoutController = TextEditingController(
|
||||
text: localSettings.smallQueueTimeoutSeconds.toString(),
|
||||
);
|
||||
_smallMaxSizeController = TextEditingController(
|
||||
text: localSettings.smallQueueMaxSize.toString(),
|
||||
);
|
||||
_largeMaxConcurrentController = TextEditingController(
|
||||
text: localSettings.largeQueueMaxConcurrent.toString(),
|
||||
);
|
||||
_largeTimeoutController = TextEditingController(
|
||||
text: localSettings.largeQueueTimeoutSeconds.toString(),
|
||||
);
|
||||
_largeMaxSizeController = TextEditingController(
|
||||
text: localSettings.largeQueueMaxSize.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_smallMaxConcurrentController.dispose();
|
||||
_smallTimeoutController.dispose();
|
||||
_smallMaxSizeController.dispose();
|
||||
_largeMaxConcurrentController.dispose();
|
||||
_largeTimeoutController.dispose();
|
||||
_largeMaxSizeController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _saveSettings() async {
|
||||
try {
|
||||
// Validate and save small queue settings
|
||||
final smallMaxConcurrent =
|
||||
int.tryParse(_smallMaxConcurrentController.text);
|
||||
final smallTimeout = int.tryParse(_smallTimeoutController.text);
|
||||
final smallMaxSize = int.tryParse(_smallMaxSizeController.text);
|
||||
|
||||
// Validate and save large queue settings
|
||||
final largeMaxConcurrent =
|
||||
int.tryParse(_largeMaxConcurrentController.text);
|
||||
final largeTimeout = int.tryParse(_largeTimeoutController.text);
|
||||
final largeMaxSize = int.tryParse(_largeMaxSizeController.text);
|
||||
|
||||
if (smallMaxConcurrent == null ||
|
||||
smallTimeout == null ||
|
||||
smallMaxSize == null ||
|
||||
largeMaxConcurrent == null ||
|
||||
largeTimeout == null ||
|
||||
largeMaxSize == null) {
|
||||
showShortToast(context, "Please enter valid numbers");
|
||||
return;
|
||||
}
|
||||
|
||||
// Basic validation - just ensure positive numbers
|
||||
if (smallMaxConcurrent < 1 ||
|
||||
largeMaxConcurrent < 1 ||
|
||||
smallTimeout < 1 ||
|
||||
largeTimeout < 1 ||
|
||||
smallMaxSize < 1 ||
|
||||
largeMaxSize < 1) {
|
||||
showShortToast(
|
||||
context,
|
||||
"All values must be positive numbers",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await localSettings.setSmallQueueMaxConcurrent(smallMaxConcurrent);
|
||||
await localSettings.setSmallQueueTimeout(smallTimeout);
|
||||
await localSettings.setSmallQueueMaxSize(smallMaxSize);
|
||||
await localSettings.setLargeQueueMaxConcurrent(largeMaxConcurrent);
|
||||
await localSettings.setLargeQueueTimeout(largeTimeout);
|
||||
await localSettings.setLargeQueueMaxSize(largeMaxSize);
|
||||
|
||||
_logger.info(
|
||||
"Local thumbnail queue settings updated:\n"
|
||||
"Small Queue - MaxConcurrent: $smallMaxConcurrent, Timeout: ${smallTimeout}s, MaxSize: $smallMaxSize\n"
|
||||
"Large Queue - MaxConcurrent: $largeMaxConcurrent, Timeout: ${largeTimeout}s, MaxSize: $largeMaxSize",
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
showShortToast(
|
||||
context,
|
||||
"Settings saved. Restart app to apply changes.",
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
showShortToast(context, "Error saving settings");
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _resetToDefaults() async {
|
||||
await localSettings.resetThumbnailQueueSettings();
|
||||
setState(() {
|
||||
_smallMaxConcurrentController.text = "15";
|
||||
_smallTimeoutController.text = "60";
|
||||
_smallMaxSizeController.text = "200";
|
||||
_largeMaxConcurrentController.text = "5";
|
||||
_largeTimeoutController.text = "60";
|
||||
_largeMaxSizeController.text = "200";
|
||||
});
|
||||
|
||||
_logger.info(
|
||||
"Local thumbnail queue settings reset to defaults:\n"
|
||||
"Small Queue - MaxConcurrent: 15, Timeout: 60s, MaxSize: 200\n"
|
||||
"Large Queue - MaxConcurrent: 5, Timeout: 60s, MaxSize: 200",
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
showShortToast(
|
||||
context,
|
||||
"Reset to defaults. Restart app to apply changes.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildNumberField({
|
||||
required String label,
|
||||
required String hint,
|
||||
required TextEditingController controller,
|
||||
}) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: getEnteTextTheme(context).body.copyWith(
|
||||
color: getEnteColorScheme(context).textMuted,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
TextField(
|
||||
controller: controller,
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||
decoration: InputDecoration(
|
||||
hintText: hint,
|
||||
hintStyle: getEnteTextTheme(context).body.copyWith(
|
||||
color: getEnteColorScheme(context).textFaint,
|
||||
),
|
||||
filled: true,
|
||||
fillColor: getEnteColorScheme(context).fillFaint,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
),
|
||||
style: getEnteTextTheme(context).body,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = getEnteColorScheme(context);
|
||||
return Scaffold(
|
||||
body: Container(
|
||||
color: colorScheme.backdropBase,
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
const TitleBarTitleWidget(
|
||||
title: "Local Thumbnail Queue Config",
|
||||
),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
"Small Local Thumbnail Queue",
|
||||
style: getEnteTextTheme(context).largeBold,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
"Used when gallery grid has 4 or more columns",
|
||||
style: getEnteTextTheme(context).small.copyWith(
|
||||
color: colorScheme.textMuted,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildNumberField(
|
||||
label: "Max Concurrent Tasks",
|
||||
hint: "Default: 15",
|
||||
controller: _smallMaxConcurrentController,
|
||||
),
|
||||
_buildNumberField(
|
||||
label: "Timeout (seconds)",
|
||||
hint: "Default: 60",
|
||||
controller: _smallTimeoutController,
|
||||
),
|
||||
_buildNumberField(
|
||||
label: "Max Queue Size",
|
||||
hint: "Default: 200",
|
||||
controller: _smallMaxSizeController,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
Text(
|
||||
"Large Local Thumbnail Queue",
|
||||
style: getEnteTextTheme(context).largeBold,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
"Used when gallery grid has less than 4 columns",
|
||||
style: getEnteTextTheme(context).small.copyWith(
|
||||
color: colorScheme.textMuted,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildNumberField(
|
||||
label: "Max Concurrent Tasks",
|
||||
hint: "Default: 5",
|
||||
controller: _largeMaxConcurrentController,
|
||||
),
|
||||
_buildNumberField(
|
||||
label: "Timeout (seconds)",
|
||||
hint: "Default: 60",
|
||||
controller: _largeTimeoutController,
|
||||
),
|
||||
_buildNumberField(
|
||||
label: "Max Queue Size",
|
||||
hint: "Default: 200",
|
||||
controller: _largeMaxSizeController,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: _saveSettings,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: colorScheme.primary700,
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
"Save Settings",
|
||||
style: getEnteTextTheme(context).bodyBold.copyWith(
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: TextButton(
|
||||
onPressed: _resetToDefaults,
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: colorScheme.primary700,
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
side: BorderSide(
|
||||
color: colorScheme.strokeMuted,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: const Text(
|
||||
"Reset to Defaults",
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.fillFaint,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.info_outline,
|
||||
size: 20,
|
||||
color: colorScheme.textMuted,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
"Changes require app restart to take effect",
|
||||
style: getEnteTextTheme(context).small.copyWith(
|
||||
color: colorScheme.textMuted,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,8 @@ class DeveloperSettingsWidget extends StatelessWidget {
|
||||
padding: const EdgeInsets.only(bottom: 20),
|
||||
child: Text(
|
||||
AppLocalizations.of(context).customEndpoint(
|
||||
endpoint: "${endpointURI.host}:${endpointURI.port}",),
|
||||
endpoint: "${endpointURI.host}:${endpointURI.port}",
|
||||
),
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -283,7 +283,9 @@ class _StorageCardWidgetState extends State<StorageCardWidget> {
|
||||
: const SizedBox.shrink(),
|
||||
Text(
|
||||
AppLocalizations.of(context).availableStorageSpace(
|
||||
freeAmount: freeSpace, storageUnit: freeSpaceUnit,),
|
||||
freeAmount: freeSpace,
|
||||
storageUnit: freeSpaceUnit,
|
||||
),
|
||||
style: getEnteTextTheme(context)
|
||||
.mini
|
||||
.copyWith(color: textFaintDark),
|
||||
|
||||
@@ -180,7 +180,8 @@ class _VerifyIdentifyDialogState extends State<VerifyIdentifyDialog> {
|
||||
.shareMyVerificationID(verificationID: verificationID)
|
||||
: AppLocalizations.of(context)
|
||||
.shareTextConfirmOthersVerificationID(
|
||||
verificationID: verificationID,),
|
||||
verificationID: verificationID,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
|
||||