Compare commits

...

348 Commits

Author SHA1 Message Date
Crowdin Bot
c001a8fe3a New Crowdin translations by GitHub Action 2025-09-08 01:17:45 +00:00
Neeraj
ba6b326f97 [mob][n] Cast app for tvOS (#7085)
## Description

## Tests
2025-09-07 08:33:46 +05:30
Neeraj Gupta
aeea35e32a Fix empty album flash after auth expiry
- Invalidate slide timer during reset and guard nextSlide() when payload cleared to stop stale timer triggering false "No media files" state
- Remove sensitive logging from EnteCrypto and CastFileService
- Redact cast token from logs

Bug fix identified with GPT-5 preview assistance.

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-07 08:28:51 +05:30
Neeraj Gupta
614c6c63aa Remove unused file 2025-09-07 07:48:48 +05:30
Manav Rathi
ba6cee23d9 [docs] Custom domains blog link (#7076) 2025-09-06 17:27:26 +05:30
Manav Rathi
e43266c176 [docs] Custom domains blog link 2025-09-06 17:24:54 +05:30
Manav Rathi
f4168cb9a3 [Docs] Similar images help entry (#6964)
## Description

Similar images help entry.
2025-09-06 16:46:31 +05:30
Neeraj Gupta
1e551b4084 Add Apple TV cast app for Ente Photos
Introduces a new tvOS application that enables users to cast and view
their Ente Photos on Apple TV. The app includes pairing functionality,
slideshow capabilities, and video playback support.

Key components:
- Cast app with SwiftUI interface for Apple TV
- EnteCast package for casting functionality and file management
- EnteNetwork package for API communication
- EnteCrypto package for secure authentication
- EnteCore package for shared utilities
- Custom fonts and branding assets
- Pairing view for device connection
- Slideshow and video player views
- Screen saver management

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-06 16:44:44 +05:30
Aman Raj Singh Mourya
df6392fd19 [locker] Update sharing package (#7071)
## Description
Move `notification_widget`, `user_avatar_widget` & `user_extension` to
`sharing` package.
Update imports, extract strings and update configuration.
2025-09-06 16:19:00 +05:30
Aman Raj Singh Mourya
e4a851072d [locker] Legacy package (#7072)
## Description

## Tests
2025-09-06 16:18:44 +05:30
AmanRajSinghMourya
f9c4442223 Update pubspec.lock 2025-09-06 15:47:33 +05:30
AmanRajSinghMourya
c4e7139ecb Fix sharing package dependencies 2025-09-06 15:15:25 +05:30
AmanRajSinghMourya
ddd4b733d3 Move user_extension to sharing package 2025-09-06 15:14:22 +05:30
Manav Rathi
3836cac109 [docs] Update custom domain docs (#7074) 2025-09-06 13:56:17 +05:30
Manav Rathi
06eda153be [docs] Update custom domain docs 2025-09-06 13:49:44 +05:30
Manav Rathi
6137d07ba8 Fix minor grammar error in deduplicate.md (#7073)
Just a minor doc fix :)
2025-09-06 13:40:22 +05:30
Waldir Pimenta
0f92b098b7 Fix minor grammar error in deduplicate.md 2025-09-06 07:51:10 +01:00
AmanRajSinghMourya
7bde215427 Extract strings 2025-09-06 06:54:43 +05:30
AmanRajSinghMourya
4953310876 Add ente_legacy package and integrate emergency services 2025-09-06 06:54:35 +05:30
AmanRajSinghMourya
2932ee7d4c Legacy package 2025-09-06 06:52:21 +05:30
AmanRajSinghMourya
0e0ba2d5af Extract strings 2025-09-06 06:47:22 +05:30
AmanRajSinghMourya
3b54fa41f6 Remove duplicate import of user_dialogs in collection_actions.dart 2025-09-06 06:45:17 +05:30
AmanRajSinghMourya
c51dff5a29 Move user_dialog to package 2025-09-06 06:45:02 +05:30
AmanRajSinghMourya
e985200e67 Minor fix 2025-09-06 06:11:31 +05:30
AmanRajSinghMourya
7e5e11ba87 Update package strings 2025-09-06 06:07:42 +05:30
AmanRajSinghMourya
13c9646f58 MInor fix 2025-09-06 05:51:39 +05:30
AmanRajSinghMourya
678b556f5f Refactor sharing components: move UserAvatarWidget and VerifyIdentityDialog to ente_sharing package, update imports, and adjust configurations 2025-09-06 05:51:30 +05:30
AmanRajSinghMourya
a3b432799a Update dependencies 2025-09-06 05:45:12 +05:30
AmanRajSinghMourya
8eaa2603dd Add VerifyIdentifyDialog widget to sharing package 2025-09-06 05:43:37 +05:30
AmanRajSinghMourya
b51febf8f5 Update dependencies 2025-09-05 23:48:32 +05:30
AmanRajSinghMourya
df522658bb Move user_avator_widget to sharing package 2025-09-05 23:46:59 +05:30
AmanRajSinghMourya
9a13b99b20 Extract strings 2025-09-05 23:45:09 +05:30
AmanRajSinghMourya
a142b660fd Add golden color properties to EnteColorScheme 2025-09-05 23:44:17 +05:30
AmanRajSinghMourya
b7dcb7b34c Add UserExtension to package 2025-09-05 23:43:53 +05:30
AmanRajSinghMourya
e8de5940fd Update dependency overrides to include ente_network and ente_sharing 2025-09-05 23:43:26 +05:30
AmanRajSinghMourya
d5f8c9eb24 Add notification widget to packages/ui 2025-09-05 23:40:15 +05:30
Neeraj
f092396133 [server] Support for coupons (#7065) 2025-09-05 15:01:15 +05:30
Neeraj Gupta
7f718438aa Minor refactor 2025-09-05 14:44:58 +05:30
Neeraj Gupta
cf7a4d989d [server] Support for coupons 2025-09-05 12:10:19 +05:30
Aman Raj Singh Mourya
e444c1801a [locker] Remove redundant packages (#7039)
## Description

## Tests
2025-09-04 22:38:47 +05:30
AmanRajSinghMourya
f2a2ee188c Update dependency & fix merge conflicts 2025-09-04 22:38:34 +05:30
AmanRajSinghMourya
356622cbb1 Merge branch 'main' into fix_packages 2025-09-04 22:25:49 +05:30
Aman Raj Singh Mourya
86c92a9217 [locker] Fix authentication not popping up on android (#7060)
## Description
Authentication service was not working on android as `local_auth`
requires the use of a `FragmentActivity` instead of an `Activity` in
`MainActivity.kt`

Also updated `AndroidManifest.xml` file to include the USE_BIOMETRIC
permissions:
2025-09-04 17:48:20 +05:30
AmanRajSinghMourya
bcc2a30105 Add USE_BIOMETRIC permission to AndroidManifest.xml 2025-09-04 16:01:33 +05:30
AmanRajSinghMourya
dcc36d2d35 Extract strings 2025-09-04 15:59:16 +05:30
AmanRajSinghMourya
d650886749 Check if isDeviceSupported for lockscreen 2025-09-04 15:58:49 +05:30
AmanRajSinghMourya
a73d5548a0 Fix: local_auth requires the use of a FragmentActivity instead of an Activity 2025-09-04 15:58:17 +05:30
Aman Raj Singh Mourya
bf0b11ebfd [locker] Locker sharing (#7013)
## Description

### Collection Sharing Feature Implementation
This PR implements a collection sharing functionality for locker,
allowing users to share collections with others and manage shared access
through various methods.

## Key Features
1. **Collection Sharing Mechanisms**
   - Share collections via links
   - Manage shared access with specific users
   - Configure link expiry and device limits

2. **User Management** in shared collections
   - Added participant (viewer/ collaborator)
   - Implemented leave collection functionality
   - Added user permissions and access controls

3. **UI Enhancements**
   - New collection view types (main, outgoing, incoming)
   - Grid view for collections
   - Enhanced menu sections and descriptions
   - Improved sharing dialogs and UI components
2025-09-04 12:32:02 +05:30
Neeraj
49c90a802a [mob] Fix changelog scrolling on small devices (#7059)
## Description

## Tests
2025-09-04 12:02:11 +05:30
Neeraj Gupta
8b2db5e576 [mob] Fix changelog scrolling on small devices 2025-09-04 12:00:11 +05:30
AmanRajSinghMourya
57382af3a2 Update imports 2025-09-03 15:44:19 +05:30
AmanRajSinghMourya
80bc848d1e Add ente_sharing package dependency to pubspec files 2025-09-03 15:41:43 +05:30
Aman Raj Singh Mourya
b11f86175e [packages] Sharing package (#7048)
## Description
Extract sharing related api to a common sharing package.
2025-09-03 15:38:48 +05:30
Neeraj
b5d4839e04 [mob] Update change log and bump version (#7052)
## Description

## Tests
2025-09-03 15:22:36 +05:30
Neeraj Gupta
ac57097eb4 Update change log and bump version 2025-09-03 15:21:12 +05:30
Ashil
4e08e38bf6 [mob][photos] Update claude md documentation (#7051)
## Description

See commit messages.
2025-09-03 13:29:55 +05:30
ashilkn
a7d3cf4178 Update storage dependencies to reflect current usage
Replace sqflite with sqlite_async as the primary database package since the project has migrated to using sqlite_async.

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-03 13:21:35 +05:30
ashilkn
c63dfc36e9 Remove integration and performance test sections from CLAUDE.md
These test commands are not confirmed to be working correctly and have been removed from the documentation.

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-03 13:20:12 +05:30
Manav Rathi
2985503254 Update CONTRIBUTING.md (#7050) 2025-09-03 12:54:24 +05:30
Laurens Priem
9be023d68a [mob][photos] Add claude.md (#7044)
## Description

Add claude.md
2025-09-03 12:35:53 +05:30
laurenspriem
6a6e1b3c47 Individual preferences 2025-09-03 12:03:51 +05:30
Neeraj
7516363715 [mob][photos] Prevent vectorDB index file corruption (#7049)
## Description

- Use `load` instead of `view`, since latter is read-only
- When loading fails in rust, delete index file in dart side and try
again
- Atomically save index file by first writing to temp file

## Tests

Tested in debug mode on my pixel phone.
2025-09-03 11:54:31 +05:30
laurenspriem
2b76b71db8 atomic save of index file 2025-09-03 11:15:07 +05:30
Manav Rathi
c32a70fb25 Update CONTRIBUTING.md 2025-09-03 10:52:03 +05:30
laurenspriem
4098c1a072 Delete index file on load error 2025-09-03 10:36:03 +05:30
laurenspriem
972be1f41e Use load for usearch index 2025-09-03 10:27:30 +05:30
AmanRajSinghMourya
2e58400962 Add analysis.yaml and minor fix 2025-09-03 10:07:27 +05:30
AmanRajSinghMourya
b0fce602aa Sharing package - extract all sharing api to a common package 2025-09-03 09:55:32 +05:30
laurenspriem
3acb2136d0 [mob][photos] Add documentation sync requirement to CLAUDE.md
Require updating associated spec documents when code changes are made

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-02 18:21:57 +05:30
laurenspriem
eba729625f commit instructions 2025-09-02 18:19:34 +05:30
Manav Rathi
a477742cd0 [web] Fix European date format search support (#7043)
Fixes #7025
2025-09-02 17:48:10 +05:30
laurenspriem
c974bde11c Don't go to setup on error 2025-09-02 17:02:51 +05:30
Manav Rathi
ecc654bae0 [web] Fix European date format search support
Fixes #7025

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-02 10:56:53 +00:00
Ashil
201ef88305 [mob][debug] Thumbnail issue debug (#7042)
## Description

For figuring out root cause of thumbnail not loading issue. This change
will not introduce any regressions or bugs.
2025-09-02 16:26:46 +05:30
Ashil
742035d7cc Merge branch 'main' into thunmbail_issue_debug 2025-09-02 16:20:43 +05:30
ashilkn
8f29d5aa19 Update internal change log 2025-09-02 16:18:15 +05:30
laurenspriem
8a4e76fb6f Small rectification 2025-09-02 16:17:10 +05:30
ashilkn
c03eaf83aa Complete completer with error if getThumbnailFromLocal throws error for task in local thumbnail task queue 2025-09-02 16:13:49 +05:30
laurenspriem
378878538d [mob][photos] Add critical coding requirements to CLAUDE.md
Add three mandatory development practices:
1. Run flutter analyze after every change - zero issues required
2. Always reuse existing components - search before creating
3. Use Ente design system - no hardcoded colors or text styles

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-02 16:06:42 +05:30
laurenspriem
01c3d6b105 [mob][photos] Add CLAUDE.md with initial project documentation
Create comprehensive development guide from /init command including:
- Project philosophy and privacy focus
- Monorepo context and structure
- Development commands (melos and flutter)
- Architecture overview with service patterns
- Security architecture details
- Development setup requirements

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-02 15:48:43 +05:30
AmanRajSinghMourya
2bdf62c490 Add melos support for photos/plugins 2025-09-02 14:08:04 +05:30
Neeraj
c6f5c68f1e [mob] Update copy (#7040)
## Description

## Tests
2025-09-02 13:52:53 +05:30
Neeraj Gupta
d0c8925ff3 Update playstore changelog 2025-09-02 13:42:46 +05:30
AmanRajSinghMourya
2cab943647 Organize imports 2025-09-02 13:41:52 +05:30
Neeraj Gupta
d6c84421ce [mob][photos] Update changelog copy translations
Updated cLTitle2 from "Manual video stream generation" to "Video streaming enhancements" across all supported locales to match the updated English copy.

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-02 13:41:46 +05:30
AmanRajSinghMourya
990485d796 Remove redundant dependencies from locker 2025-09-02 13:41:43 +05:30
AmanRajSinghMourya
96e9030d40 Move list extension to packages 2025-09-02 12:37:59 +05:30
Neeraj
0d1f20f9e2 [mob][photos] Clear up flutter analyze (#7035)
## Description

- Replace withOpacity() with withValues(alpha:)
- Replace onPopInvoked with onPopInvokedWithResult
- Update MaterialState references to WidgetState
- Organize imports
- Remove unneeded nullability
- Dangling library docs
- collectionName deprecation warning
- TextInputWidget isPasswordInput deprecation warning
2025-09-02 12:29:26 +05:30
Ashil
c55447a08f [mob][debug] To debug thumbnail not loading (#7036) 2025-09-02 12:28:34 +05:30
Ashil
98d56e8fa4 Merge branch 'main' into thunmbail_issue_debug 2025-09-02 12:26:48 +05:30
ashilkn
f244c94ebf Update internal change log 2025-09-02 12:24:01 +05:30
laurenspriem
88f2b88f4d Remove deprecation warnings 2025-09-02 12:15:07 +05:30
Neeraj
db1fef40db [mob] Update changelog (#7034)
## Description

## Tests
2025-09-02 12:14:36 +05:30
laurenspriem
1fd29cdd13 dangling library doc 2025-09-02 12:02:53 +05:30
laurenspriem
947d294afe non nullable dialog 2025-09-02 12:02:36 +05:30
ashilkn
515715660e Add option to config local thumbnail queue to debug thumbnail not displaying issue + add more logging + show local ID of file on thumbnails (configurable) 2025-09-02 11:53:21 +05:30
laurenspriem
324221171d organize imports 2025-09-02 11:50:01 +05:30
laurenspriem
f5f2ff1b2c Fix Flutter deprecation warnings
- Replace withOpacity() with withValues(alpha:)
- Replace onPopInvoked with onPopInvokedWithResult
- Update MaterialState references to WidgetState

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-02 11:49:12 +05:30
Neeraj Gupta
244d41621c Bump version 2025-09-02 11:41:40 +05:30
Neeraj Gupta
91b6a08a35 Update changelog entries with new features
- Replace old changelog entries with new ones across all supported languages
- Add Similar Images, Manual video stream generation, and Performance Improvements features
- Remove outdated entries for Advanced Image Editor, Smart Albums, Improved Gallery, and Faster Scroll

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-02 11:39:54 +05:30
Neeraj
770a311da5 [auth] Fix manual app lock with macos touch id (#6793)
## Description
This fixes https://github.com/ente-io/ente/issues/3428
This was broken because of
https://github.com/eaceto/flutter_local_authentication/issues/8
I've also added that if the app is locked manually, the macOS Touch ID
API won't be called until the user either presses the unlock button
again or unfocuses the app and then focuses back on it. This behavior
also applies when the app window is closed and then reopened.
2025-09-02 10:46:56 +05:30
Neeraj
db76dee639 fix: only show when video streaming is enabled (#7031)
## Description

## Tests
2025-09-02 10:45:37 +05:30
Manav Rathi
20ce760e85 feat(rust): Initialize Rust CLI foundation (#6915)
## Summary
Rust CLI achieves feature parity with Go CLI for photos app core
functionality

## Changes
- Export, sync, and incremental updates working
- Hash-based deduplication and live photo support
- Public magic metadata for renamed files
- Progress indicators for downloads

## Remaining
- Export filters (album, date range)
- Resume interrupted downloads
- Shared/hidden album support
2025-09-02 09:57:56 +05:30
Prateek Sunal
df1bfbe839 fix: initialize compute controllers async with values 2025-09-02 02:51:09 +05:30
Prateek Sunal
27d72eb821 fix: make continuation and releasing compute better 2025-09-02 02:44:11 +05:30
Prateek Sunal
98786c5824 fix: move logs at better place 2025-09-02 01:04:24 +05:30
Prateek Sunal
d38a09c3f0 perf: optimize video stream processing state management
- Move isCurrentlyProcessing to widget state for better performance
- Only call setState when processing state actually changes
- Add comprehensive processing status handling (retry, compressing, uploading)
- Remove redundant service calls from build method
- Clean up unnecessary early returns and duplicate logic

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-01 18:50:00 +00:00
Aman Raj Singh Mourya
91785d8c90 Add Parallels custom icon (#7026)
## Description
This PR adds a custom icon for Parallels.

- Added `parallels.svg` under
`mobile/apps/auth/assets/custom-icons/icons/`
- Updated `custom-icons.json` with:
  - title: "Parallels"
  - slug: "parallels"
  - hex: #E61E25
  - altNames: ["Parallels Desktop", "Parallels VM"]

The icon is optimized (well under 50KB) and uses the official Parallels
red (#E61E25).
2025-09-02 00:19:46 +05:30
Prateek Sunal
b1f28e3f2e chore: update locks 2025-09-01 23:35:43 +05:30
Prateek Sunal
c155bdd058 chore: lint fixes 2025-09-01 23:35:30 +05:30
Prateek Sunal
a859f28e2c fix: show queueed or creatingStream based on context 2025-09-01 23:35:25 +05:30
Prateek Sunal
8d75528aa5 fix: introduce in queue and creating stream two types of statuses 2025-09-01 23:35:08 +05:30
Prateek Sunal
7f43c11985 fix: only show when video streaming is enabled 2025-09-01 21:22:17 +05:30
Manav Rathi
aadda7e3f6 feat(export): Add file deletion and rename detection to match Go CLI
Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-01 18:01:01 +05:30
Ashil
210c18d244 Update internal changes (#7030) 2025-09-01 17:32:12 +05:30
Ashil
6636849838 update internal changes 2025-09-01 17:26:20 +05:30
Neeraj
5500315351 [mob][photos] fix unsupported locales in language selector (#7029) 2025-09-01 17:24:32 +05:30
Prateek Sunal
562292e642 fix: remove unsupported languages from language picker
Remove languages from _getLanguageName that don't have >90% translation
coverage and aren't in appSupportedLocales (Finnish, Korean, Arabic).
Also improve Chinese locale display.

- Removed fi, ko, ar cases that don't meet translation threshold
- Fixed Chinese locale handling to properly show "中文 (简体)" for zh_CN
- Ensures only properly translated languages appear in the picker

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-01 17:13:58 +05:30
Prateek Sunal
4aa80edbcf fix: resolve unsupported locales appearing in language selector
Replace AppLocalizations.supportedLocales with a curated list of properly
translated locales in the Photos app. This fixes the issue where unsupported
language codes (Bg, Be, Ca, Cs, etc.) were appearing in the language selector
without proper language name formatting.

- Add custom appSupportedLocales list with only >90% translated languages
- Update all references throughout Photos app to use the custom locale list
- Ensures only properly supported languages appear in the language picker

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-01 17:02:15 +05:30
Neeraj
9524a639cd [server] Fix collection link for locker (#6961)
## Description

## Tests
2025-09-01 16:28:19 +05:30
Neeraj
b8eb793c16 [mobile/photos] New translations (#7022)
New translations from
[Crowdin](https://crowdin.com/project/ente-photos-app)
2025-09-01 16:26:51 +05:30
Ashil
4b514f1e1a [mob][photos] Revert "Revert diskLoadDeferDuration to 500ms" (#7028)
This reverts commit a295f223b6.
2025-09-01 16:25:59 +05:30
eYdr1en
bee2bb9621 remove unusuded variable 2025-09-01 12:45:27 +02:00
ashilkn
772121c22e Revert "Revert diskLoadDeferDuration to 500ms"
This reverts commit a295f223b6.
2025-09-01 16:07:40 +05:30
eYdr1en
3c49ca0f6e Merge branch 'main' into touch-id 2025-09-01 12:35:27 +02:00
Onurcan
f2e51893ad Update Parallels custom-icons.json 2025-09-01 11:59:32 +03:00
Onurcan
c08b78c775 dd Parallels custom icon 2025-09-01 11:58:26 +03:00
Manav Rathi
233f03355f Fix security issues and match Go CLI error handling
Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-01 13:16:03 +05:30
Laurens Priem
73ab50f113 [mob][photos] Run vectorDB migration is memory safe way (#7024)
## Description

- Add ability to block computeController temporarily
- Block computeController when vectorDB migration is running
2025-09-01 11:44:39 +05:30
laurenspriem
4a2346fe93 Block compute when vectorDB migration is happening 2025-09-01 11:11:10 +05:30
laurenspriem
68b5cce158 Add option to block compute tasks (ml, streaming) 2025-09-01 11:09:51 +05:30
laurenspriem
e907a9e8cb comment 2025-09-01 10:59:02 +05:30
Manav Rathi
92a40afca2 [web] New translations (#7021)
New translations from
[Crowdin](https://crowdin.com/project/ente-photos-web)
2025-09-01 10:22:50 +05:30
Aman Raj Singh Mourya
0c2b38c059 Fixing dev build on macos (#7012)
This should fix this error
https://github.com/ente-io/ente/pull/6768#discussion_r2310164866
2025-09-01 09:26:51 +05:30
Crowdin Bot
19650bcd57 New Crowdin translations by GitHub Action 2025-09-01 01:05:33 +00:00
Crowdin Bot
2b9ca073ce New Crowdin translations by GitHub Action 2025-09-01 00:45:39 +00:00
Manav Rathi
2257087bb2 Fix file rename handling to match Go CLI behavior
- Add rename detection by tracking files via ID in metadata
- Remove old files (including live photo MOV components) when renamed
- Copy live photo MOV components during hash deduplication
- Preserve file deduplication optimization while handling renames correctly

This ensures that when a file is renamed in Ente, the old file is removed
and replaced with the renamed version, matching the Go CLI's behavior.

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-31 21:56:14 +05:30
Manav Rathi
2a5bce2ae4 Fix live photo export to preserve original file extensions
Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-31 10:36:25 +05:30
Manav Rathi
1e0a6eb1ea Add persistent storage for public magic metadata
Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-31 09:28:00 +05:30
Manav Rathi
187a729013 Update CLAUDE.md documentation to reflect current codebase
Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-31 09:10:50 +05:30
Manav Rathi
c98f4dfffd fix(rust): Match Go CLI email filtering behavior
Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-31 07:39:59 +05:30
Manav Rathi
4140a0f6fe feat(rust): Add shared album decryption support
Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-31 06:54:33 +05:30
Aman Raj Singh Mourya
cf4b87dad9 [locker] Refactor theme handling in Locker to fix DynamicFAB style (#7016)
## Description
DynamicFAB theme was not getting applied from the commons package. This
PR fix that issue.

## Tests

**Before**

<img width="300" height="750" alt="Simulator Screenshot - iPhone 16 Plus
- 2025-08-30 at 15 53 26"
src="https://github.com/user-attachments/assets/17dfc778-b652-4e10-ad8f-3c8aed2656f6"
/>


**After**

<img width="300" height="750" alt="Simulator Screenshot - iPhone 16 Plus
- 2025-08-30 at 15 52 58"
src="https://github.com/user-attachments/assets/9e0c2feb-8204-4875-9bad-f9d4eaab8f36"
/>
2025-08-30 16:01:01 +05:30
AmanRajSinghMourya
3fd0db6a90 Refactor theme handling in locker to fix DynamicFAB 2025-08-30 15:52:34 +05:30
AmanRajSinghMourya
a9d5773b9a Fix sync after leaving collection 2025-08-30 15:12:37 +05:30
Manav Rathi
ac68b99ecf Fix shared collection deserialization
Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-30 10:53:44 +05:30
Manav Rathi
82e1a0e358 Fix hidden album filtering to match Go CLI
Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-30 10:37:45 +05:30
Laurens Priem
ce1701d211 [mob][photos] Similar small fixes (#7008)
## Description

Small design changes and fixes.

## Tests

Tested in debug mode on my pixel phone.
2025-08-30 07:29:27 +05:30
Manav Rathi
034e789242 fix(rust): Validate account exists before update/delete
Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-29 22:02:35 +05:30
Manav Rathi
ccfec4071f fix(rust): Match Go CLI JSON field naming for ID fields
Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-29 21:51:34 +05:30
Manav Rathi
c4830732fd fix(rust): Format timestamps as ISO 8601 in metadata JSON
Changed metadata export to match Go CLI's timestamp format.
Timestamps now serialize as ISO 8601 strings with timezone offset
(e.g., "2025-07-23T19:48:06.098+05:30") instead of Unix microseconds.

Also fixed clippy warnings to ensure CI compliance.

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-29 21:41:33 +05:30
Manav Rathi
72dc56e41f fix(rust): Correct album-based export to match Go CLI
Fixed export to properly organize files into album folders by:
- Fetching files from all collections using /collections/v2/diff endpoint
- Decrypting encrypted collection names to get actual album names
- Using decrypted album names for folder organization

Files now export to proper album folders instead of all going to
"Uncategorized". Tested and verified with local data.

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-29 21:20:30 +05:30
AmanRajSinghMourya
8dd3ad9f5b Extract strings + minor fix 2025-08-29 20:23:46 +05:30
AmanRajSinghMourya
2ebb920faa Show leave collection options 2025-08-29 20:20:49 +05:30
AmanRajSinghMourya
e9f55b968a Refactor home page to manage collection file counts separately for main, outgoing, and incoming collections 2025-08-29 20:20:26 +05:30
AmanRajSinghMourya
5036a8da59 Add method to leave collection 2025-08-29 20:20:16 +05:30
Laurens Priem
aaed336991 [infra] Release action changes (#7010)
## Description

- Disk space cleanup
- Generate rust bindings
2025-08-29 20:05:31 +05:30
eYdr1en
0b85dfe7e4 fixing dev build on macos 2025-08-29 15:53:56 +02:00
Prateek Sunal
68422b172f [mob][photos] fix manual video streaming when ML is enabled (#7009) 2025-08-29 18:53:53 +05:30
laurenspriem
db99dae3e1 log line 2025-08-29 18:48:00 +05:30
laurenspriem
3717a156d3 Logging unexpected embeddings 2025-08-29 18:47:01 +05:30
Prateek Sunal
ca9930e01b style: fix import directive ordering in thumbnail_widget 2025-08-29 12:50:13 +00:00
laurenspriem
eb23a4e770 rust bindings 2025-08-29 18:19:50 +05:30
laurenspriem
e03303e5b3 release workflow disk cleanup 2025-08-29 18:18:45 +05:30
laurenspriem
2ad27f1c6e Clear similar images json cache 2025-08-29 18:03:27 +05:30
Prateek Sunal
202e6a9f7c fix: trigger processing for already-queued manual stream files
When users click "Create Stream" on files already in queue from
previous sessions, ensure processing actually starts even if the
file was previously stalled due to ML blocking.

Add forceProcess parameter to queueFiles() to bypass the existing
queue check and trigger processing of stalled manual queue items.
2025-08-29 12:28:48 +00:00
laurenspriem
ceaedad327 debug option to delete vectorDB index 2025-08-29 17:57:21 +05:30
Prateek Sunal
fd963a1c8e fix: allow manual video stream creation when ML is waiting
When ML is enabled but not running, the compute controller blocks
all stream requests due to _waitingToRunML flag. This prevents
users from manually creating video streams even though ML isn't
actively using resources.

Add bypassMLWaiting parameter to allow manual stream creation
to proceed regardless of ML waiting state, improving UX.
2025-08-29 12:22:08 +00:00
laurenspriem
b40b5bb1ae delete progress 2025-08-29 17:07:44 +05:30
laurenspriem
91827626b2 dot dot dot 2025-08-29 17:04:33 +05:30
laurenspriem
42318335ae Fix issue with deleting favorites 2025-08-29 16:16:51 +05:30
laurenspriem
858db62385 Left align large files 2025-08-29 16:09:10 +05:30
laurenspriem
46e36612d3 Scroll to top after delete 2025-08-29 15:49:09 +05:30
laurenspriem
62cf236e3b Cycle through loading screen texts 2025-08-29 15:27:11 +05:30
Ashil
c2b1ab86f2 [mob][photos] Fix incorrect file deletion from db when widget unmounts during thumbnail loading (#7007)
## Description

Previously, when _loadWithRetry returned null due to widget unmounting,
the code incorrectly assumed the local file was deleted and would remove
database reference of the file and which would trigger re-upload of the
file.
2025-08-29 15:17:55 +05:30
laurenspriem
43adf42281 Don't auto select favorites for deletions 2025-08-29 15:00:42 +05:30
laurenspriem
1e2a65281c Fix delete button bug 2025-08-29 14:32:12 +05:30
ashilkn
70eb68b13c Fix incorrect file deletion when widget unmounts during thumbnail loading
Previously, when _loadWithRetry returned null due to widget unmounting,
the code incorrectly assumed the local file was deleted and would remove
database references or delete the file. This could lead to data loss.

Changes:
- Add new WidgetUnmountedException to centralized exceptions.dart for reuse
- Throw WidgetUnmountedException instead of returning null when widget unmounts
- Handle WidgetUnmountedException separately in error handler with appropriate logging
- Still set _errorLoadingLocalThumbnail flag to prevent retry attempts

Using Exception instead of Error follows Dart conventions:
- Exceptions are for recoverable runtime conditions (like widget unmounting)
- Errors are for programming mistakes that shouldn't be caught

This ensures that widget unmounting is properly distinguished from actual
file access failures.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-29 14:17:49 +05:30
Ashil
fa86b19307 [mob][photos] Update internal change log (#7006) 2025-08-29 14:10:19 +05:30
Ashil
e632dc7771 Merge branch 'main' into update_int_change_log 2025-08-29 14:08:58 +05:30
ashilkn
7fa9adb636 update internal change log 2025-08-29 14:08:00 +05:30
Ashil
83f885f158 [mob][photos] Revert diskLoadDeferDuration to 500ms (#7005)
## Description
2025-08-29 13:58:58 +05:30
ashilkn
a295f223b6 Revert diskLoadDeferDuration to 500ms
Reverts the change from commit 1f1cad181f
which reduced galleryThumbnailDiskLoadDeferDuration from 500ms to 80ms.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-29 12:57:42 +05:30
AmanRajSinghMourya
6775faf0d0 Fix naming 2025-08-29 12:43:30 +05:30
Laurens Priem
cc64ef8035 [infra] Create more space for internal release action (#7004)
## Description

- Removed redundant SDKs
- Removed redundant rust install (already pre-installed)
- Delete old other action
2025-08-29 12:41:28 +05:30
Ashil
69dd7b6233 [mob][photos] Spacing (#7002)
## Description

Add spacing in similar images page.
2025-08-29 12:41:11 +05:30
AmanRajSinghMourya
367dc18caa Add sharing functionality for collections and update routing logic 2025-08-29 12:33:41 +05:30
AmanRajSinghMourya
0c6db4661e Refractor item_list_view.dart and split code into multiple file for better redability 2025-08-29 12:33:17 +05:30
Ashil
bcc9f1be73 [mob][photos] Revert cache extent changes (#7000)
## Description

The reverted changes were intended to solve the issue #6957 fixed. So
these changes are no longer needed and there are doubts if they are
causing regressions related to thumbnail loading.

## Tests
2025-08-29 12:26:29 +05:30
Ashil
296b2a2a6c Merge branch 'main' into revert-cache-extent-changes 2025-08-29 12:24:03 +05:30
laurenspriem
6b48c9bc34 Remove ineffective cleanup steps 2025-08-29 12:23:47 +05:30
ashilkn
6a951bcc72 Update internal change log 2025-08-29 12:23:28 +05:30
laurenspriem
38914981a1 Fix disk space calculation in cleanup step 2025-08-29 11:52:36 +05:30
laurenspriem
66f4d5b1a6 Add disk cleanup step to free space in GitHub Actions
Removes unused pre-installed software to free ~30-45GB:
- .NET SDK (~20-25GB)
- Haskell compiler (~5-8GB)
- Boost libraries (~1-2GB)
- Cached tool versions (~5-10GB)

Includes timing and space metrics for each removal

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-29 11:43:07 +05:30
laurenspriem
9ee3781320 spacing 2025-08-29 10:30:05 +05:30
laurenspriem
907d1d2bb8 delete rust install (already on runner env by default) 2025-08-29 10:27:46 +05:30
laurenspriem
8218283463 delete old action 2025-08-29 10:26:32 +05:30
Laurens Priem
bd43385949 [mob][photos] Similar index clear (#6997)
## Description

- Clear vectorDB index on logout
- Revert to using `view` on index
- Use `.usearch` for index file
- Minor design changes

## Tests

Tested in debug mode on my pixel phone.
2025-08-28 18:57:28 +05:30
Laurens Priem
2e6a9acaf9 Merge branch 'main' into similar_index_clear 2025-08-28 18:57:11 +05:30
Ashil
a02dcace7d [mob][photos] New Ducky launcher icons (#6999) 2025-08-28 18:56:34 +05:30
Laurens Priem
cf4285de6d Merge branch 'main' into similar_index_clear 2025-08-28 18:50:12 +05:30
laurenspriem
f831491e4a log changes 2025-08-28 18:30:29 +05:30
laurenspriem
af154d82de ducky analyzing riv animation 2025-08-28 18:17:11 +05:30
Prateek Sunal
ff2f75ea74 [mob][photos] bypass size/duration limits for manual video stream requests (#6998)
## Summary
- Modified `_checkFileForPreviewCreation` method to accept `isManual`
parameter
- Bypass 500MB file size and 60 second duration limits when user
manually triggers video stream processing
- Maintains size/duration restrictions for automatic streaming to
preserve device performance

## Test plan
- [x] Manual Create/Recreate Stream button bypasses 500MB and 60 second
limits
- [x] Automatic streaming still respects size and duration restrictions
- [x] Files larger than 500MB or longer than 60 seconds can be manually
processed
2025-08-28 17:51:26 +05:30
laurenspriem
97e3ef819a Change migration key because index file was changed (internal only) 2025-08-28 17:43:18 +05:30
Prateek Sunal
3685cd2154 fix: don't show create stream if file size is null 2025-08-28 17:34:29 +05:30
laurenspriem
c64fff8ca4 empty tab state ducky 2025-08-28 17:25:54 +05:30
ashilkn
23dc809589 Remove hardcoded cacheExtent to use Flutter's default value 2025-08-28 17:07:46 +05:30
laurenspriem
33d1242c6d strings 2025-08-28 17:03:53 +05:30
laurenspriem
b8ee9fafd1 Better confirmation dialog 2025-08-28 17:03:24 +05:30
ashilkn
f72c9fa068 Revert "Different cache extents for different photoGridSizes"
This reverts commit 769adb75c5.
2025-08-28 16:55:39 +05:30
Ashil
1a7275a101 Merge branch 'main' into ducky_icon 2025-08-28 16:17:58 +05:30
Prateek Sunal
fa7ccbd180 fix: if fileSize is null for manual way then skip 10MB check 2025-08-28 16:16:15 +05:30
ashilkn
79e26d6993 Update internal change log 2025-08-28 16:16:04 +05:30
ashilkn
023135afb5 Remove green background from ducky icon display
Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-28 16:07:32 +05:30
Prateek Sunal
04aaa3a5e4 fix: bypass size/duration limits for manual video stream requests
Allow manual stream requests to bypass the 500MB file size and 60-second
duration limits by passing isManual parameter to _checkFileForPreviewCreation.
This ensures users can manually process large files even if they exceed the
automatic streaming limits.
2025-08-28 10:25:45 +00:00
laurenspriem
848857f409 same close related 2025-08-28 15:27:33 +05:30
ashilkn
137033be67 Update Android ducky launcher icon
Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-28 15:23:51 +05:30
AmanRajSinghMourya
b6489f4c41 Add color for avatar 2025-08-28 15:17:53 +05:30
AmanRajSinghMourya
e7d7f1cdd0 Add user management features to sharing collection page and actions 2025-08-28 15:17:42 +05:30
AmanRajSinghMourya
bbbdd96c9e Add functionality for managing album participants and sharing settings 2025-08-28 15:17:12 +05:30
AmanRajSinghMourya
3c23d3b480 Extract strings 2025-08-28 15:16:45 +05:30
AmanRajSinghMourya
3805cddeba Add ListExtension and UserExtension for enhanced list and user functionalities 2025-08-28 15:16:29 +05:30
AmanRajSinghMourya
824c324342 Add MenuSectionDescriptionWidget and MenuSectionTitle components 2025-08-28 15:16:18 +05:30
laurenspriem
04b6f4a765 right threshold 2025-08-28 15:05:58 +05:30
laurenspriem
2645ba0949 Change index file name to use usearch 2025-08-28 13:13:16 +05:30
laurenspriem
5958647fa8 Delete vectorDB index file on logout 2025-08-28 13:11:05 +05:30
laurenspriem
b7b91631f6 revert back to view on index 2025-08-28 13:01:26 +05:30
Prateek Sunal
67d7f586b2 [mob][photos] bypass interaction check for manual stream requests (#6993)
## Summary
- Manual Create/Recreate Stream button presses now bypass user
interaction timer for immediate processing
- Fixed multiple concurrent streaming processes bug in ComputeController
- Fixed video streaming description text display spacing in advanced
settings
- Maintains device health and ML priority checks for all streaming
requests

## Tests
- [x] Manual Create/Recreate Stream button bypasses interaction timer  
- [x] Automatic streaming still respects interaction timer
- [x] Only one streaming process allowed at a time
2025-08-27 21:15:55 +05:30
Prateek Sunal
7c22a8bb25 chore: lint fix 2025-08-27 21:10:18 +05:30
Prateek Sunal
ff3864a09a fix: check only if permission granted before chunking 2025-08-27 21:09:10 +05:30
Prateek Sunal
4484b9e4ad update: add video streaming improvements to change logs
Co-authored-by: Claude <noreply@anthropic.com>
2025-08-27 13:21:59 +00:00
Prateek Sunal
e9554ffbcb fix: prevent multiple concurrent streaming processes
Remove condition allowing additional stream requests when already streaming to ensure only one stream process runs at a time.

Co-authored-by: Claude <noreply@anthropic.com>
2025-08-27 13:15:10 +00:00
Prateek Sunal
ad3901d484 fix: remove conditional clearQueue for manual processing
Co-authored-by: Claude <noreply@anthropic.com>
2025-08-27 13:08:12 +00:00
Prateek Sunal
ecca4c3dc8 feat: bypass interaction check for manual stream requests
Co-authored-by: Claude <noreply@anthropic.com>
2025-08-27 13:00:21 +00:00
Prateek Sunal
d05521f884 [mob][photos] video streaming description spacing and alignment (#6992)
## Summary
- Split videoStreamingDescription into separate line1/line2 localization
keys
- Remove TextAlign.justify from enabled state to fix awkward word
spacing
- Standardize text rendering between enabled and disabled states
- Both states now display description consistently without spacing
issues

## Test plan
- [x] Verify enabled state displays as single line without spacing
issues
- [x] Verify disabled state shows proper line breaks in onboarding
- [x] Confirm localization keys generate correctly
- [x] Run dart format and dart analyze (no issues)

Fixes video streaming settings page text display inconsistencies.
2025-08-27 18:08:28 +05:30
Prateek Sunal
ff37c4bf81 fix: video streaming description spacing and alignment
- Split videoStreamingDescription into separate line1/line2 localization keys
- Remove TextAlign.justify from enabled state to fix awkward word spacing
- Standardize text rendering between enabled and disabled states
- Both states now display description consistently without spacing issues

Co-authored-by: Claude <noreply@anthropic.com>
2025-08-27 12:03:31 +00:00
ashilkn
446df755fa Add ducky icon for iOS
Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-27 17:23:31 +05:30
Manav Rathi
0f5e30e96b feat(rust): Add metadata export matching Go CLI format
Export album and file metadata to .meta folders within each album directory.
Enables incremental sync and compatibility with Go CLI exports.

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-27 17:22:17 +05:30
Manav Rathi
35ded7bc59 fix(rust): Match Go CLI's album-based export directory structure
Switch from date-based (YYYY/MM-Month) to album-based directory structure
to ensure compatibility with Go CLI. Files now export to AlbumName/ folders
with "Uncategorized" for files without albums.

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-27 17:06:40 +05:30
ashilkn
a7805784b7 Add ducky icon for Android
Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-27 17:01:09 +05:30
Manav Rathi
8e3f6e56d2 feat(rust): Remove sync command to match Go CLI interface
Align with Go CLI by integrating sync into export workflow.
Update CLAUDE.md to prevent default template usage in commits.

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-27 16:51:04 +05:30
AmanRajSinghMourya
6ded21fe87 Add CollectionViewType enum and update CollectionPage 2025-08-27 14:35:11 +05:30
AmanRajSinghMourya
be4b521879 Extract strings 2025-08-27 14:32:34 +05:30
AmanRajSinghMourya
326eb3ff8a Add getPublicKey method to UserService for retrieving public keys by email 2025-08-27 13:49:01 +05:30
AmanRajSinghMourya
adef8bd466 Extract strings & add constants 2025-08-27 13:48:08 +05:30
AmanRajSinghMourya
a1d9fb5969 Add function to handle sharing actions 2025-08-27 13:46:58 +05:30
AmanRajSinghMourya
6da615b7dc Refactor ManageSharedLinkWidget to enable link expiry and device limit features with updated UI components 2025-08-27 13:45:56 +05:30
AmanRajSinghMourya
41a268b1cb Add crypto, bip39, dotted_border packages 2025-08-27 13:43:57 +05:30
AmanRajSinghMourya
ed07e64fa5 Add new UI components and dialogs for sharing features 2025-08-27 13:42:56 +05:30
Neeraj
84a5ad0b86 [mob][photos] More minor design changes for similar images (#6986)
## Description

- Change tab order
- Make tabs distinct
- Change default ordering to size

## Tests

Tested in debug mode on my pixel phone.
2025-08-27 13:25:40 +05:30
laurenspriem
44ad11343a Better empty state prompt for emtpy tab 2025-08-27 13:17:41 +05:30
laurenspriem
07e50e3cfe Change default sort to size 2025-08-27 13:03:30 +05:30
laurenspriem
df8bbdb788 Make identical and similar distinct 2025-08-27 13:01:56 +05:30
laurenspriem
1ed381fe52 Change order of tabs 2025-08-27 12:52:45 +05:30
ashilkn
55090436ce Add new ducky-icon assets 2025-08-27 11:21:36 +05:30
Manav Rathi
150534aa1a feat(rust): Add deduplication, live photos, and update docs
- Hash-based file deduplication prevents duplicate exports
- Live photo extraction from ZIP archives
- Update conversion status documenting feature completion
- Make commit guidelines prominent in CLAUDE.md
- Remove redundant commit format section

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-27 07:53:22 +05:30
Laurens Priem
ddd1d5ac86 [mob][photos] Similar images UX changes (#6981)
## Description

Similar images UX changes

## Tests

Tested in debug mode on my pixel phone.
2025-08-26 23:50:33 +05:30
Laurens Priem
26845a502e [mob][photos] Use load instead of view on index (#6980)
## Description

Use load instead of index
2025-08-26 23:48:59 +05:30
laurenspriem
21aac29020 format count properly 2025-08-26 23:48:31 +05:30
AmanRajSinghMourya
bdfe363066 Minor UI fix 2025-08-26 23:46:38 +05:30
laurenspriem
c1ff02df14 Always select all on tab change 2025-08-26 23:39:21 +05:30
laurenspriem
e4927c4022 Merge branch 'main' into similar_ux_changes 2025-08-26 23:30:32 +05:30
laurenspriem
4fd797338b Empty state 2025-08-26 23:26:51 +05:30
laurenspriem
eca0e5943d tab button look 2025-08-26 23:24:37 +05:30
laurenspriem
56cc7309a5 Show progress only for multiple albums symlinking 2025-08-26 23:05:04 +05:30
laurenspriem
b740d1af05 Show modal on 100+ deleted files only 2025-08-26 23:01:30 +05:30
laurenspriem
6d21b73367 faster select 2025-08-26 22:59:30 +05:30
laurenspriem
a5704eef25 Use load instead of view on index 2025-08-26 22:46:33 +05:30
laurenspriem
7e83682686 tiny margin in threshold 2025-08-26 22:42:04 +05:30
laurenspriem
18d5aa61b0 Extract string 2025-08-26 22:34:59 +05:30
laurenspriem
7c2a719ba8 (un)select all 2025-08-26 22:32:11 +05:30
Manav Rathi
2a136ba087 fix(rust): Fix file counting logic in sync and export commands
Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-26 21:46:52 +05:30
laurenspriem
47313a74ff Tab bar filter 2025-08-26 21:28:21 +05:30
Manav Rathi
3abb479fbf feat(rust): Add progress indicators for downloads
Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-26 21:26:39 +05:30
Manav Rathi
7eda60a493 fix(rust): Fix incremental sync to properly track per-collection timestamps
Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-26 21:15:11 +05:30
Manav Rathi
bb8c5caa8d feat(rust): Handle renamed files using public magic metadata
Check both public magic metadata (for edited names) and regular metadata
when determining file names during export and sync, matching Go CLI behavior

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-26 21:00:11 +05:30
Prateek Sunal
65a7a16298 [mob][photos] fixes (#6979)
## Description

- [x] Fix Spacing in Video streaming settings
- [x] Update copy in Video Streaming settings
- [x] Disable debug notifications for work manager in iOS

## Tests
2025-08-26 20:57:02 +05:30
Prateek Sunal
9251e4f5b6 fix: update spacing and remove cross icon from top right 2025-08-26 19:07:44 +05:30
Prateek Sunal
c4bc6abf83 fix: remove debug mode notification flag 2025-08-26 19:07:27 +05:30
Manav Rathi
0384819c01 Take 2 2025-08-26 18:06:05 +05:30
Manav Rathi
f55973367d feat(rust): Add retry logic and export filters
- Add configurable retry with exponential backoff for API calls
- Handle 429 and 5xx errors with automatic retries
- Add export filters for albums, shared, and hidden collections
- Fix formatting and clippy warnings to pass CI checks

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-26 17:54:57 +05:30
laurenspriem
3165289483 Remove header 2025-08-26 17:48:39 +05:30
laurenspriem
01aab41c25 Select all by default 2025-08-26 17:46:53 +05:30
laurenspriem
1826258161 Copy 2025-08-26 17:43:58 +05:30
laurenspriem
df5917060b Copy change 2025-08-26 17:40:20 +05:30
Prateek Sunal
b5aa05cc1b [mob][photos] merge migration scripts (#6974)
## Description

Fixes #6923

## Tests
2025-08-26 17:16:10 +05:30
Prateek Sunal
cd865992f2 chore: directly use database 2025-08-26 17:02:21 +05:30
Prateek Sunal
370c0ab54a fix: merge migration scripts 2025-08-26 16:02:15 +05:30
Manav Rathi
699794226f fix(rust): Fix sync command file downloads
- Handle non-interactive mode in account add command
- Fix cross-filesystem file move issue by using copy+delete instead of rename
- Successfully tested downloading files from local server

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-26 16:00:15 +05:30
Manav Rathi
dee68acfc3 docs(rust): Reduce verbosity
Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-26 15:31:06 +05:30
eYdr1en
0bd5452837 Merge remote-tracking branch 'upstream/main' into touch-id 2025-08-26 11:07:30 +02:00
Manav Rathi
e53ddb8b51 refactor(rust): Remove backward compatibility code
Since the CLI hasn't been released yet, we don't need to maintain
backward compatibility. This commit removes unnecessary compatibility
code to simplify the codebase.

Changes:
- Remove id field from Account struct (use user_id directly)
- Remove update_file_local_path legacy wrapper method
- Use mark_file_synced directly instead of the wrapper
- Update all references from account.id to account.user_id

This results in cleaner, more maintainable code without unnecessary
compatibility layers.

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-26 10:12:26 +05:30
Manav Rathi
95d167878e refactor(rust): Eliminate redundant primary keys using global uniqueness
Since user_id is globally unique in Ente's system (like collection_id and
file_id), we can eliminate artificial primary keys and use the actual IDs
directly. This simplifies the schema and reduces redundancy.

Changes:
- Use (user_id, app) composite primary key in accounts table
- Use (user_id, app) composite primary key in secrets table
- Remove account_id references, use user_id directly
- Update collections table to use owner field (user_id)
- Update files table to use owner_id field (user_id)
- Remove account_id from album_files table
- Update sync_state table to use (user_id, app) primary key
- Update all storage methods to use new schema
- Update commands to pass correct parameters to storage methods
- Update indices for better query performance

This aligns with Ente's API design where these IDs are guaranteed to be
globally unique, eliminating the need for artificial primary keys.

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-26 10:04:24 +05:30
Manav Rathi
653fc47aed fix(rust): Fix clippy warning for collapsible if statement
Collapsed nested if statement in sync.rs to satisfy clippy's
collapsible-if lint rule. This change is required for CI to pass
with the updated Rust version.

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-26 09:43:21 +05:30
Manav Rathi
34325691e7 refactor(rust): Use collection_id and file_id as primary keys
Since collection_id and file_id are globally unique across all users in
Ente's API, we can use them directly as primary keys instead of creating
artificial auto-increment IDs. This simplifies the schema and reduces
redundancy.

Changes:
- Use collection_id as primary key in collections table
- Use file_id as primary key in files table
- Use composite primary key (album_id, file_id) in album_files table
- Update all related SQL queries to match new schema
- Add appropriate foreign key constraints
- Optimize indices for the new structure

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-26 09:30:05 +05:30
Manav Rathi
e474114e22 fix(rust): Fix clippy warnings and improve CI documentation
- Fix collapsible if statement warnings in sync.rs and files.rs
- Update CLAUDE.md with clearer CI requirements
- Remove misleading auto-fix command that doesn't catch all issues
- Emphasize that ALL checks must pass before committing

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-26 09:14:33 +05:30
Manav Rathi
80c07d36a9 feat(rust): Complete sync command with file downloads
- Integrated DownloadManager with sync command for actual file downloads
- Implemented proper sync state tracking using is_synced_locally flag
- Fixed database persistence by preserving sync state during updates
- Added proper collection key decryption for file downloads
- Files are only downloaded once and marked as synced
- Cleaned up schema - removed migrations since this is new code
- Fixed deserialization issues with RemoteFile thumbnail field
- Added proper error handling for missing collection keys

The sync command now:
1. Fetches metadata for collections and files
2. Downloads files that haven't been synced yet
3. Marks files as synced to avoid re-downloading
4. Properly handles existing files on disk

This matches the Go CLI's approach of using a synced flag rather than
checking file existence on every sync.

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-26 06:27:09 +05:30
Manav Rathi
8581742a73 feat(rust): Integrate DownloadManager with sync command
- Added local_path column to files table for tracking downloaded files
- Implemented get_pending_downloads() to find files without local_path
- Integrated DownloadManager into sync command for full file downloads
- Added collection key decryption for file downloads
- Generate proper export paths with date/album structure
- Track successful downloads and update database with local paths
- Added migration to add local_path column to existing databases

The sync command now supports full file downloads (not just metadata).
Files are downloaded to the export directory with proper organization.

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-26 05:55:03 +05:30
Manav Rathi
042dae8790 fix(rust): Apply cargo fmt and clippy fixes
- Fixed formatting issues in sync/engine.rs
- Added #[allow(dead_code)] for unused storage field in DownloadManager
- Replaced manual clamp with .clamp() method

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-26 05:41:33 +05:30
laurenspriem
45249e0cdf add similar images entry 2025-08-25 18:24:02 +05:30
laurenspriem
ebfcedac7b update outdated 2025-08-25 18:22:32 +05:30
AmanRajSinghMourya
2900ca55f5 Extract Strings + minor fix 2025-08-25 15:10:08 +05:30
AmanRajSinghMourya
2a40aa472e Add parameters to share URL request 2025-08-25 14:53:03 +05:30
AmanRajSinghMourya
62cb67f3bf Minor UI improvements 2025-08-25 11:47:11 +05:30
AmanRajSinghMourya
e393b92a3d Add sharing functionality to CollectionPage 2025-08-25 11:46:51 +05:30
AmanRajSinghMourya
e06d65e8a0 Minor fix 2025-08-25 11:46:08 +05:30
AmanRajSinghMourya
a4ec8c939a Add ManageSharedLinkWidget and ShareCollectionPage for link management functionality 2025-08-25 11:45:59 +05:30
AmanRajSinghMourya
b8dd379306 Enhance AllCollectionsPage to support multiple collection view types 2025-08-25 11:44:49 +05:30
AmanRajSinghMourya
42229bd331 Refactor HomePage to integrate shared collections 2025-08-25 11:41:49 +05:30
AmanRajSinghMourya
ad9a3977a3 add helper method & cache collections for faster access 2025-08-25 11:35:16 +05:30
AmanRajSinghMourya
afb93df48f Add CollectionFlexGridViewWidget and SectionTitle components 2025-08-25 11:31:32 +05:30
AmanRajSinghMourya
4ce38ecea0 Added sharedCollections 2025-08-25 11:30:37 +05:30
AmanRajSinghMourya
4c63c8fc25 Add shareURL methods to CollectionApiClient 2025-08-25 11:30:09 +05:30
Neeraj Gupta
bc6506cb10 Fix link for locker 2025-08-25 10:50:05 +05:30
Neeraj Gupta
f2a26ba391 Minor refactor 2025-08-25 10:35:47 +05:30
AmanRajSinghMourya
158b48e4dc Add SharingNotPermittedForFreeAccountsError error 2025-08-24 23:58:22 +05:30
Manav Rathi
84f5a5ac3d feat(rust): Add sync command and fix database path
- Add new `sync` command to fetch collections and file metadata
- Change config directory from ~/.ente/ to ~/.config/ente-cli/ to avoid conflicts with Go CLI
- Fix sync engine to use correct API endpoints (/collections/v2/diff instead of /diff)
- Implement per-collection file syncing matching Go CLI behavior
- Fix foreign key constraints in database schema
- Add metadata-only and full sync options
- Store database path in Storage struct for creating new instances
- Successfully tested with real account: syncs 5 files and exports correctly

The sync command now properly fetches all collections and files from the API,
storing them in SQLite for offline access and incremental sync support.

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-23 16:47:08 +05:30
Manav Rathi
a00fc0b1be fix(rust): Remove sensitive information from logs and docs
Security improvements:
- Remove all debug logs that output tokens, keys, or credentials
- Remove email addresses from debug output
- Remove encrypted keys and nonces from logs
- Remove specific account references from documentation
- Add security guidelines to CLAUDE.md

No sensitive information (PII, credentials, tokens) should be logged
even in debug mode. Updated guidelines to prevent future occurrences.

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-23 15:06:28 +05:30
Manav Rathi
f5347e7436 docs(rust): Update conversion plan with completed features
- Mark streaming XChaCha20-Poly1305 implementation as complete
- Document successful export functionality with all decryption working
- Update testing status with successful real account exports
- Add recent achievements section highlighting key milestones
- Update feature parity progress checklist
- Document what components are complete vs remaining

The export functionality is now fully working with proper decryption
of collections, files, and metadata. Updated PR description as well.

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-23 14:26:09 +05:30
Manav Rathi
3f1d574d0c feat(rust): Add progress indicators to export
- Show progress for each exported file with count
- Improve export summary with emojis and better formatting
- Add contextual success messages based on export results
- Make export output more user-friendly

The export now provides clear feedback during the process and
a helpful summary at the end.

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-23 14:14:59 +05:30
Manav Rathi
891b68c0f4 fix(rust): Add chunked decryption for large files
- Implement chunked streaming decryption matching Go's 4MB buffer size
- Update file decryption to use decrypt_file_data instead of decrypt_stream
- Successfully tested with 33MB RAW image file
- All test files now decrypt correctly

Large files are now properly handled with chunked decryption, preventing
memory issues and matching the Go implementation's behavior.

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-23 14:12:06 +05:30
Manav Rathi
f050c6f9d7 feat(rust): Implement streaming XChaCha20-Poly1305 decryption
- Add streaming cipher module using libsodium's secretstream API
- Update file and metadata decryption to use streaming XChaCha20-Poly1305
- Fix decryption issues - files now properly decrypt
- Successfully tested with real account - exports working for smaller files

The export now correctly decrypts files using the same streaming cipher
as the Go implementation. Large files may need chunked decryption support.

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-23 14:01:59 +05:30
Manav Rathi
2de67b619f feat(rust): Add metadata decryption for original filenames
- Create metadata module with FileMetadata struct
- Decrypt file metadata to extract original filename
- Use original filename in export path generation
- Add proper file type detection from metadata
- Implement filename sanitization for filesystem safety

Files are now exported with their original names instead of generic IDs.

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-23 13:25:38 +05:30
Manav Rathi
828dde5ca7 feat(rust): Implement file decryption in export command
- Add ChaCha20-Poly1305 decryption for downloaded files
- Decrypt file keys using master key
- Extract nonce from encrypted file data
- Add basic filename generation with extension detection
- Comment out sync modules temporarily due to model mismatches

The export command now properly decrypts files instead of saving them encrypted.
Next steps: extract original filenames from decrypted metadata.

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-23 13:03:46 +05:30
Manav Rathi
2526c69896 docs(rust): Update pre-commit checklist to match CI configuration
Ensure clippy commands use --all-targets --all-features flags to match
the CI environment exactly. This prevents CI failures from warnings that
weren't caught locally.

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-23 12:09:42 +05:30
Manav Rathi
6e64a2067f fix(rust): Resolve all clippy warnings for CI compliance
- Fix lifetime elision warnings in storage/mod.rs by adding explicit lifetimes
- Collapse nested if-let statements in export.rs using let-chains
- Code now passes: cargo clippy --all-targets --all-features

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-23 09:58:29 +05:30
Manav Rathi
ab4792518f docs(rust): Add mandatory pre-commit checklist to CLAUDE.md
Add explicit pre-commit commands that must be run before every commit
to ensure CI passes. These commands simulate the CI environment locally.

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-23 09:29:28 +05:30
Manav Rathi
d4ae8d63fc fix(rust): Fix linting and formatting issues for CI
- Applied cargo fmt to ensure consistent formatting
- Fixed all clippy warnings (uninlined_format_args)
- Code now passes all CI checks with RUSTFLAGS="-D warnings"

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-23 09:01:56 +05:30
Manav Rathi
618753cb1a feat(rust): Implement export command with collection-based file fetching
- Fix token encoding to use base64 URL with padding (matching Go implementation)
- Add export command that iterates through collections and fetches files
- Update API models to handle actual server response field names (ownerID vs ownerId)
- Fix file download URLs for local/dev environments
- Implement proper directory structure creation (YYYY/MM-Month format)
- Add collection attributes and public URL models for complete API compatibility
- Successfully exports encrypted files from both local and production endpoints

The export command now:
- Fetches all collections for an account
- Iterates through each collection to get files
- Downloads encrypted files and saves them to the export directory
- Skips already downloaded files to support incremental exports

Note: Files are still encrypted; decryption will be implemented in a future commit.

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-22 21:13:52 +05:30
Manav Rathi
f84bd20bbf feat(rust): Store API endpoint per account for better environment isolation
- Add endpoint field to accounts database table with default to production API
- Update Account model to include endpoint field
- Add --endpoint flag to account add command only
- Remove ENTE_ENDPOINT environment variable support
- Update account list to display endpoints in readable format
- Each account now maintains its own endpoint, preventing confusion between test and production environments

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-22 20:25:39 +05:30
Manav Rathi
6ae7aa70d6 fix(rust): Fix FFI type cast and clippy warnings for CI
- Use std::ffi::c_char for libsodium FFI context parameter cast
- Fix all clippy warnings to pass CI with RUSTFLAGS="-D warnings"
- Update CLAUDE.md with FFI casting guidance for future development

This ensures the code passes all CI checks including the stricter
clippy settings used in GitHub Actions.

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-22 18:49:24 +05:30
Manav Rathi
48757af5d0 fix(rust): Fix SRP authentication implementation
Fixed issues preventing successful authentication:
- Corrected Argon2 memory limit handling (API sends bytes, not KB)
- Replaced Blake2b with crypto_kdf_derive_from_key for login subkey derivation
- Fixed serde field names to match API expectations (srpUserID, sessionID)
- Added non-interactive mode for CLI testing
- Added support for ENTE_ENDPOINT environment variable

The implementation now matches the web client's key derivation exactly,
enabling successful authentication with both local and production servers.

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-22 18:23:16 +05:30
Manav Rathi
cd20a98850 feat(rust): Implement account management commands
Add comprehensive account management functionality with secure SRP authentication.
This enables users to add, list, update, and manage multiple Ente accounts
for photos, locker, and auth apps.

Key features:
- Complete account add flow with SRP authentication
- Two-factor authentication support (TOTP)
- Secure key decryption and storage
- Multi-account support with per-app configuration
- Account list and update commands
- Export directory management
- Interactive CLI prompts with dialoguer

The implementation integrates with the API client for authentication and
securely stores account credentials in SQLite.

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-22 13:57:29 +05:30
Manav Rathi
9ac9e6bd26 feat(rust): Implement comprehensive API client for Ente services
Add complete API client implementation with authentication, file operations,
and collection management. This enables the Rust CLI to interact with Ente
servers for photo backup and sync operations.

Key features:
- Multi-account token management with secure storage
- SRP authentication flow matching Go implementation
- Retry logic with exponential backoff for network resilience
- Full API coverage: auth, collections, files, trash, user details
- Request/response models for all API endpoints
- Separate download client for large file transfers
- Smart CDN routing for production file downloads

The implementation follows the conversion plan and maintains compatibility
with the existing Go CLI API patterns.

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-22 12:14:05 +05:30
Manav Rathi
0b640c9062 docs(rust): Enhance CLAUDE.md with comprehensive codebase guidance
Add detailed development commands, architecture overview, and module
descriptions to help future Claude instances understand the codebase
structure and development workflow.

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-22 10:38:57 +05:30
Manav Rathi
2d87aba165 docs(rust): Add comprehensive conversion plan
- Document current implementation status
- Detail API client implementation steps
- List all remaining components with specifications
- Include testing strategy and migration notes
- Provide file structure reference for navigation
- Add implementation guidelines and environment variables

This plan enables any developer to understand the project state
and continue the conversion work from the current point.

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-22 10:03:01 +05:30
Manav Rathi
7dffdfaecf feat(rust): Implement SQLite storage layer
- Replace sled with SQLite for better reliability and tooling
- Create schema with tables for accounts, secrets, collections, files, and sync state
- Implement account storage with multi-account support
- Add configuration and sync state management
- Support for storing encrypted credentials separately
- Add indices for performance optimization

SQLite provides ACID transactions, better debugging tools, and a proven
track record for reliability with user data.

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-22 09:50:59 +05:30
Manav Rathi
a4da7b5555 main safeguard 2025-08-22 09:26:55 +05:30
Manav Rathi
85b766b5d0 Safeguard 2025-08-21 16:31:35 +05:30
Manav Rathi
62f715d3c1 fix(rust): Use std::ffi::c_char for FFI type casting
- Replace libc::c_char with std::ffi::c_char for password parameter
- Remove unnecessary libc dependency
- Use standard library FFI types (available since Rust 1.64)

This fixes the CI build error where libsodium expects *const c_char
for the password parameter in crypto_pwhash.

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-21 16:27:50 +05:30
Manav Rathi
e35ae86fa5 Ask it to run cargo fmt etc
For the current session cc was able to use that instruction to figure out all the linters etc to run. If that doesn't work in future sessions, we can use a longer instruction, something like what it itself suggested

    ## CI/CD Requirements
    - Must pass `cargo fmt --check`
    - Must pass `cargo clippy --all-targets --all-features`
    - Must pass `RUSTFLAGS="-D warnings" cargo build`
    - Fix all formatting before committing
    - Address all clippy warnings
    - Use `#![allow(dead_code)]` during development for unused code

    ## Code Quality
    - Run `cargo fmt` before committing
    - Fix clippy warnings: remove unnecessary casts, use idiomatic Rust
    - Prefix unused variables with underscore
    - Remove unused imports
2025-08-21 12:33:14 +05:30
Manav Rathi
ea843eba7a fix(rust): Address cargo fmt and clippy warnings
- Fix code formatting with cargo fmt
- Remove unnecessary type casts
- Use range contains instead of manual comparison
- Prefix unused variables with underscore
- Remove unused imports
- Add allow(dead_code) for development phase

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-21 12:24:04 +05:30
Manav Rathi
b845f4d893 Keep co-author but remove the self promo link
Typical Claude Code commit message:
feat: implement user authentication

- Added login endpoint
- Implemented JWT tokens
- Created middleware

Created with Claude Code: https://claude.ai/code  # <-- The promotional link
Co-authored-by: Claude <claude@anthropic.com>      # <-- The co-author line

This memory is to ask claude to keep the co-author line but remove the self promo from the commit message it creates.
2025-08-21 06:51:22 +05:30
Manav Rathi
8ea36acb7a feat(rust): Initialize Rust CLI foundation with libsodium crypto
- Set up project structure mirroring Go CLI architecture
- Add dependencies with libsodium-sys-stable for all crypto operations
- Implement core crypto module with Argon2, ChaCha20-Poly1305, and Blake2b
- Create data models for accounts, files, and collections
- Set up Clap-based CLI framework with account, export, and version commands
- Add error handling with thiserror
- Configure for static linking to create standalone binaries

This establishes the foundation for converting the Ente CLI from Go to Rust,
with a focus on maintaining compatibility with existing libsodium-based crypto.

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-21 06:50:23 +05:30
eYdr1en
279df8ff57 fixes lint error 2025-08-13 12:52:13 +02:00
Adrián Horváth
d83994c692 Update mobile/apps/auth/lib/ui/tools/lock_screen.dart
Co-authored-by: Prateek Sunal <prtksunal@gmail.com>
2025-08-13 12:37:11 +02:00
eYdr1en
be506bdad1 fixes macos touch id lock 2025-08-08 17:22:12 +02:00
414 changed files with 42555 additions and 2266 deletions

View File

@@ -27,6 +27,38 @@ jobs:
with:
submodules: recursive
- name: Free up disk space
run: |
echo "Initial disk usage:"
df -h /
# Get available space in KB
INITIAL=$(df / | awk 'NR==2 {print $4}')
echo -e "\n=== Removing .NET SDK (~20-25GB) ==="
BEFORE=$(df / | awk 'NR==2 {print $4}')
START=$(date +%s)
sudo rm -rf /usr/share/dotnet
END=$(date +%s)
AFTER=$(df / | awk 'NR==2 {print $4}')
FREED=$(( (AFTER - BEFORE) / 1048576 )) # Convert KB to GB
echo "Time: $((END-START))s | Freed: ${FREED}GB"
echo -e "\n=== Removing cached tools (~5-10GB) ==="
BEFORE=$(df / | awk 'NR==2 {print $4}')
START=$(date +%s)
sudo rm -rf "$AGENT_TOOLSDIRECTORY"
END=$(date +%s)
AFTER=$(df / | awk 'NR==2 {print $4}')
FREED=$(( (AFTER - BEFORE) / 1048576 ))
echo "Time: $((END-START))s | Freed: ${FREED}GB"
echo -e "\n=== Final Summary ==="
FINAL=$(df / | awk 'NR==2 {print $4}')
TOTAL_FREED=$(( (FINAL - INITIAL) / 1048576 ))
echo "Total space freed: ${TOTAL_FREED}GB"
echo "Final disk usage:"
df -h /
- name: Setup JDK 17
uses: actions/setup-java@v1
with:
@@ -39,11 +71,6 @@ jobs:
flutter-version: ${{ env.FLUTTER_VERSION }}
cache: true
- name: Install Rust ${{ env.RUST_VERSION }}
uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ env.RUST_VERSION }}
- name: Install Flutter Rust Bridge
run: cargo install flutter_rust_bridge_codegen

View File

@@ -1,77 +0,0 @@
name: "Old Internal release (photos)"
on:
workflow_dispatch: # Allow manually running the action
env:
FLUTTER_VERSION: "3.32.8"
RUST_VERSION: "1.85.1"
permissions:
contents: write
jobs:
build:
runs-on: ubuntu-latest
defaults:
run:
working-directory: mobile/apps/photos
steps:
- name: Checkout code and submodules
uses: actions/checkout@v4
with:
submodules: recursive
- name: Setup JDK 17
uses: actions/setup-java@v1
with:
java-version: 17
- name: Install Flutter ${{ env.FLUTTER_VERSION }}
uses: subosito/flutter-action@v2
with:
channel: "stable"
flutter-version: ${{ env.FLUTTER_VERSION }}
cache: true
- name: Install Rust ${{ env.RUST_VERSION }}
uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ env.RUST_VERSION }}
- name: Install Flutter Rust Bridge
run: cargo install flutter_rust_bridge_codegen
- name: Setup keys
uses: timheuer/base64-to-file@v1
with:
fileName: "keystore/ente_photos_key.jks"
encodedString: ${{ secrets.SIGNING_KEY_PHOTOS }}
- name: Build PlayStore AAB
run: |
flutter build appbundle --dart-define=cronetHttpNoPlay=true --release --flavor playstore
env:
SIGNING_KEY_PATH: "/home/runner/work/_temp/keystore/ente_photos_key.jks"
SIGNING_KEY_ALIAS: ${{ secrets.SIGNING_KEY_ALIAS_PHOTOS }}
SIGNING_KEY_PASSWORD: ${{ secrets.SIGNING_KEY_PASSWORD_PHOTOS }}
SIGNING_STORE_PASSWORD: ${{ secrets.SIGNING_STORE_PASSWORD_PHOTOS }}
- name: Upload AAB to PlayStore
uses: r0adkll/upload-google-play@v1
with:
serviceAccountJsonPlainText: ${{ secrets.SERVICE_ACCOUNT_JSON }}
packageName: io.ente.photos
releaseFiles: mobile/apps/photos/build/app/outputs/bundle/playstoreRelease/app-playstore-release.aab
track: internal
- name: Notify Discord
uses: sarisia/actions-status-discord@v1
with:
webhook: ${{ secrets.DISCORD_INTERNAL_RELEASE_WEBHOOK }}
nodetail: true
title: "🏆 Internal release Photos (Branch: ${{ github.ref_name }})"
description: "[Download](https://play.google.com/store/apps/details?id=io.ente.photos)"
color: 0x00ff00

View File

@@ -28,6 +28,38 @@ jobs:
with:
submodules: recursive
- name: Free up disk space
run: |
echo "Initial disk usage:"
df -h /
# Get available space in KB
INITIAL=$(df / | awk 'NR==2 {print $4}')
echo -e "\n=== Removing .NET SDK (~20-25GB) ==="
BEFORE=$(df / | awk 'NR==2 {print $4}')
START=$(date +%s)
sudo rm -rf /usr/share/dotnet
END=$(date +%s)
AFTER=$(df / | awk 'NR==2 {print $4}')
FREED=$(( (AFTER - BEFORE) / 1048576 )) # Convert KB to GB
echo "Time: $((END-START))s | Freed: ${FREED}GB"
echo -e "\n=== Removing cached tools (~5-10GB) ==="
BEFORE=$(df / | awk 'NR==2 {print $4}')
START=$(date +%s)
sudo rm -rf "$AGENT_TOOLSDIRECTORY"
END=$(date +%s)
AFTER=$(df / | awk 'NR==2 {print $4}')
FREED=$(( (AFTER - BEFORE) / 1048576 ))
echo "Time: $((END-START))s | Freed: ${FREED}GB"
echo -e "\n=== Final Summary ==="
FINAL=$(df / | awk 'NR==2 {print $4}')
TOTAL_FREED=$(( (FINAL - INITIAL) / 1048576 ))
echo "Total space freed: ${TOTAL_FREED}GB"
echo "Final disk usage:"
df -h /
- name: Setup JDK 17
uses: actions/setup-java@v1
with:
@@ -40,6 +72,12 @@ jobs:
flutter-version: ${{ env.FLUTTER_VERSION }}
cache: true
- name: Install Flutter Rust Bridge
run: cargo install flutter_rust_bridge_codegen
- name: Generate Rust bindings
run: flutter_rust_bridge_codegen generate
- name: Setup keys
uses: timheuer/base64-to-file@v1
with:

View File

@@ -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.

View File

@@ -44,7 +44,7 @@ The first step is to let Ente know about the domain or subdomain you wish to use
> [!WARNING]
>
> Currently (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.

View File

@@ -6,7 +6,7 @@ description: Removing duplicates photos using Ente Photos
# Deduplicate
Ente performs two different duplicate detections: one during uploads, and one
that can be manually run afterwards to remove duplicates 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

View File

@@ -1236,6 +1236,12 @@
"title": "Parqet",
"slug": "parqet"
},
{
"title": "Parallels",
"slug": "parallels",
"hex": "#E61E25",
"altNames": ["Parallels Desktop", "Parallels VM"]
},
{
"title": "Parsec"
},

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<rect x="20" y="10" width="10" height="80" rx="5" fill="#E61E25"/>
<rect x="50" y="10" width="10" height="80" rx="5" fill="#E61E25"/>
</svg>

After

Width:  |  Height:  |  Size: 207 B

View File

@@ -111,6 +111,7 @@
"importAegisGuide": "استخدم خيار \"Export the vault\" في إعدادات Aegis.\n\nإذا كان المخزن الخاص بك مشفرًا، فستحتاج إلى إدخال كلمة مرور المخزن لفك تشفير المخزن.",
"import2FasGuide": "استخدم خيار \"الإعدادات -> النسخ الاحتياطي - التصدير\" في 2FAS.\n\nإذا تم تشفير النسخة الاحتياطية، سوف تحتاج إلى إدخال كلمة المرور لفك تشفير النسخة الاحتياطية",
"importLastpassGuide": "استخدم خيار \"حسابات النقل\" ضمن إعدادات مصادقة Lastpass، واضغط على \"تصدير الحسابات إلى الملف\". استيراد JSON الذي تم تنزيله.",
"importProtonAuthGuide": "‎استخدم اختيار “التصدير” في إعدادات الموثق بروتون لتصدير رموزك.",
"exportCodes": "تصدير الرموز",
"importLabel": "استيراد",
"importInstruction": "الرجاء تحديد ملف يحتوي على قائمة بالرموز الخاصة بك بالشكل التالي",
@@ -519,5 +520,12 @@
"algorithm": "الخوارزمية",
"type": "النوع",
"period": "المدّة",
"digits": "الأرقام"
"digits": "الأرقام",
"importFromGallery": "‎استيراد من معر الصور ",
"errorCouldNotReadImage": "تعذر قراءة ‎الصورة المحدد.",
"errorInvalidQRCode": "رمز QR غير صالح",
"errorInvalidQRCodeBody": "رمز QR الممسوح ليس حساب 2FA صحيح.",
"errorNoQRCode": "لم يتم العثور على رمز QR",
"errorGenericTitle": "حدث خطأ",
"errorGenericBody": "‎حدث خطأ غير متوقع خلال الاستيراد."
}

View File

@@ -157,6 +157,7 @@
"enterCodeHint": "Zadejte 6místný kód ze své autentizační aplikace",
"lostDeviceTitle": "Ztratili jste zařízení?",
"twoFactorAuthTitle": "Dvoufaktorové ověření",
"passkeyAuthTitle": "Ověření pomocí přístupového klíče",
"verifyPasskey": "Ověřit přístupový klíč",
"loginWithTOTP": "Přihlášení s TOTP",
"recoverAccount": "Obnovit účet",
@@ -173,7 +174,7 @@
"invalidQRCode": "Neplatný QR kód",
"noRecoveryKeyTitle": "Nemáte obnovovací klíč?",
"enterEmailHint": "Zadejte svou e-mailovou adresu",
"enterNewEmailHint": "Zadejte svou novou e-mailovou adresu",
"enterNewEmailHint": "Zadejte novou e-mailovou adresu",
"invalidEmailTitle": "Neplatná e-mailová adresa",
"invalidEmailMessage": "Prosím, zadejte platnou e-mailovou adresu.",
"deleteAccount": "Odstranit účet",
@@ -512,7 +513,7 @@
"supportEnte": "Podpořte <bold-green>ente</bold-green>",
"giveUsAStarOnGithub": "Dejte nám hvězdu na Githubu",
"free5GB": "5GB zdarma na <bold-green>ente</bold-green> Fotky",
"loginWithAuthAccount": "Přihlaste se pomocí svého účtu Auth",
"loginWithAuthAccount": "Přihlásit se pomocí účtu Auth",
"freeStorageOffer": "10% sleva na <bold-green>ente</bold-green> fotky",
"freeStorageOfferDescription": "Použijte kód \"AUTH\" pro získání 10% slevy na první rok",
"advanced": "Pokročilé",

View File

@@ -89,7 +89,7 @@
"changePassword": "Muuda salasõna",
"data": "Andmed",
"importCodes": "Impordi koode",
"importTypePlainText": "Votmindamata tekstina",
"importTypePlainText": "Vormindamata tekstina",
"importTypeEnteEncrypted": "Ente krüptitud ekspordina",
"passwordForDecryptingExport": "Salasõna eksporditud andmete dekrüptimiseks",
"passwordEmptyError": "Salasõna väli ei saa olla tühi",
@@ -214,7 +214,40 @@
"@iUnderStand": {
"description": "Text for the button to confirm the user understands the warning"
},
"authToExportCodes": "Oma koodide eksportimiseks palun tuvasta end",
"importSuccessDesc": "Sa oled importinud {count} koodi!",
"@importSuccessDesc": {
"placeholders": {
"count": {
"description": "The number of codes imported",
"type": "int",
"example": "1"
}
}
},
"pendingSyncsWarningBody": "Mõned sinu koodid on varundamata.\n\nEnne väljalogimist palun kontrolli, et sa oleksid nad varundanud.",
"tapToEnterCode": "Koodi sisestamiseks klõpsa",
"terminateSession": "Kas lõpetad sessiooni?",
"terminate": "Lõpeta",
"thisDevice": "See seade",
"toResetVerifyEmail": "Salasõna lähtestamiseks palun esmalt kinnita oma e-posti aadress.",
"thisEmailIsAlreadyInUse": "See e-posti aadress on juba kasutuses",
"verificationFailedPleaseTryAgain": "Kinnitamine ei õnnestunud, palun proovi uuesti",
"yourVerificationCodeHasExpired": "Sinu verifitseerimiskood on aegunud",
"incorrectCode": "Vigane kood",
"sorryTheCodeYouveEnteredIsIncorrect": "Vabandust, sinu sisestatud kood on vigane",
"emailChangedTo": "E-posti aadress on nüüd muudetud, uus aadress on {newEmail}",
"authenticationFailedPleaseTryAgain": "Autentimine ei õnnestunud, palun proovi uuesti",
"authenticationSuccessful": "Autentimine õnnestus!",
"incorrectRecoveryKey": "Vigane taasetvõti",
"theRecoveryKeyYouEnteredIsIncorrect": "Sinu sisestatud taasetvõti on vigane",
"enterPassword": "Sisesta salasõna",
"selectExportFormat": "Vali ekspordivorming",
"exportDialogDesc": "Krüptitud ekspordifailid on kaitstud sinu sisestatud salasõnaga.",
"encrypted": "Krüptitud",
"plainText": "Vormindamata tekst",
"pinnedCodeMessage": "{code} on tõstetud esile",
"unpinnedCodeMessage": "{code} esiletõstmine on lõppenud",
"createNewTag": "Lisa uus silt",
"tag": "Silt",
"create": "Loo",
@@ -225,5 +258,9 @@
"reEnterPassword": "Sisesta salasõna uuesti",
"setNewPassword": "Sisesta uus salasõna",
"enterPin": "Sisesta PIN-kood",
"setNewPin": "Määra uus PIN-kood"
"setNewPin": "Määra uus PIN-kood",
"plainHTML": "Tavaline HTML",
"errorInvalidQRCode": "Vigane QR-kood",
"errorInvalidQRCodeBody": "Skaneeritud QR-kood pole korrektne 2FA kasutajakonto.",
"errorNoQRCode": "QR-koodi ei leidu"
}

View File

@@ -6,7 +6,7 @@
"@counterAppBarTitle": {
"description": "Text shown in the AppBar of the Counter Page"
},
"onBoardingBody": "Sauvegarder vos codes A2F",
"onBoardingBody": "Sécurisez vos codes A2F",
"onBoardingGetStarted": "Premiers pas",
"setupFirstAccount": "Configurez votre premier compte",
"importScanQrCode": "Scannez un QR Code",

View File

@@ -111,6 +111,7 @@
"importAegisGuide": "Gunakan opsi \"Export the vault\" di Pengaturan Aegis.\n\nJika brankas Anda terenkripsi, Anda perlu memasukkan kata sandi brankas untuk mendekripsi brankas.",
"import2FasGuide": "Gunakan opsi \"Settings->Backup -Export\" di 2FAS.\n\nJika cadangan Anda terenkripsi, Anda perlu memasukkan kata sandi untuk mendekripsi cadangan",
"importLastpassGuide": "Gunakan opsi \"Transfer accounts\" di Pengaturan Lastpass Authenticator dan tekan \"Export accounts to file\". Impor file JSON yang diunduh.",
"importProtonAuthGuide": "Gunakan opsi \"Ekspor\" di pengaturan Proton Authenticator untuk mengekspor kode anda.",
"exportCodes": "Ekspor kode",
"importLabel": "Impor",
"importInstruction": "Harap pilih file yang berisi daftar kode Anda dalam format berikut",
@@ -500,16 +501,31 @@
"appLockOfflineModeWarning": "Anda telah memilih untuk mengunci aplikasi tanpa cadangan apa pun. Jika Anda lupa kode Pengunci Apl Anda, Anda tidak akan dapat mengakses data-data Anda.",
"duplicateCodes": "Kode duplikat",
"noDuplicates": "✨ Tak ada duplikat",
"youveNoDuplicateCodesThatCanBeCleared": "Anda tidak memiliki kode duplikat yang dapat dihapus",
"deduplicateCodes": "Hapus kode duplikat",
"deselectAll": "Batalkan semua pilihan",
"selectAll": "Pilih semua",
"deleteDuplicates": "Hapus duplikat",
"plainHTML": "HTML Sederhana",
"tellUsWhatYouThink": "Berikan pendapatmu",
"dropReviewiOS": "Berikan ulasan di App Store",
"dropReviewAndroid": "Berikan ulasan di Play Store",
"supportEnte": "Dukung <bold-green>ente</bold-green>",
"giveUsAStarOnGithub": "Beri kami bintang di Github",
"free5GB": "5GB gratis di <bold-green>ente</bold-green> Photos",
"loginWithAuthAccount": "Masuk dengan akun Auth anda",
"freeStorageOffer": "Potongan 10% di <bold-green>ente</bold-green> photos",
"freeStorageOfferDescription": "Gunakan kode \"AUTH\" untuk mendapatkan potongan 10% untuk tahun pertama",
"advanced": "Lanjutan",
"algorithm": "Algoritma",
"type": "Tipe",
"period": "Periode",
"digits": "Digit"
"digits": "Digit",
"importFromGallery": "Impor dari galeri",
"errorCouldNotReadImage": "Tidak dapat membaca file gambar yang dipilih.",
"errorInvalidQRCode": "Kode QR tidak valid",
"errorInvalidQRCodeBody": "Kode QR yang dipindai bukan akun 2FA yang valid.",
"errorNoQRCode": "Kode QR tidak ditemukan",
"errorGenericTitle": "Terjadi kesalahan",
"errorGenericBody": "Terjadi kesalahan yang tidak terduga saat mengimpor."
}

View File

@@ -519,5 +519,12 @@
"algorithm": "Algoritmo",
"type": "Tipo",
"period": "Periodo",
"digits": "Cifre"
"digits": "Cifre",
"importFromGallery": "Importa dalla galleria",
"errorCouldNotReadImage": "Impossibile leggere il file immagine selezionato.",
"errorInvalidQRCode": "Codice QR non valido",
"errorInvalidQRCodeBody": "Il codice QR scansionato non è un account 2FA valido.",
"errorNoQRCode": "Nessun codice QR trovato",
"errorGenericTitle": "Si è verificato un errore",
"errorGenericBody": "Si è verificato un errore imprevisto durante l'importazione."
}

View File

@@ -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,

View File

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

View File

@@ -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() {
}

View File

@@ -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

View File

@@ -7,8 +7,7 @@ import 'package:ente_events/event_bus.dart';
import 'package:ente_events/models/signed_in_event.dart';
import 'package:ente_events/models/signed_out_event.dart';
import 'package:ente_strings/l10n/strings_localizations.dart';
import 'package:ente_ui/theme/colors.dart';
import 'package:ente_ui/theme/ente_theme_data.dart';
import "package:ente_ui/theme/ente_theme_data.dart";
import 'package:ente_ui/utils/window_listener_service.dart';
import 'package:flutter/foundation.dart';
import "package:flutter/material.dart";
@@ -87,37 +86,14 @@ class _AppState extends State<App>
@override
Widget build(BuildContext context) {
final schemes = ColorSchemeBuilder.fromCustomColors(
primary700: const Color(0xFF1565C0), // Dark blue
primary500: const Color(0xFF2196F3), // Material blue
primary400: const Color(0xFF42A5F5), // Light blue
primary300: const Color(0xFF90CAF9), // Very light blue
iconButtonColor: const Color(0xFF1976D2), // Custom icon color
gradientButtonBgColors: const [
Color(0xFF1565C0),
Color(0xFF2196F3),
Color(0xFF42A5F5),
],
);
final lightTheme = createAppThemeData(
brightness: Brightness.light,
colorScheme: schemes.light,
);
final darkTheme = createAppThemeData(
brightness: Brightness.dark,
colorScheme: schemes.dark,
);
Widget buildApp() {
if (Platform.isAndroid ||
Platform.isWindows ||
Platform.isLinux ||
kDebugMode) {
return AdaptiveTheme(
light: lightTheme,
dark: darkTheme,
light: lightThemeData,
dark: darkThemeData,
initial: AdaptiveThemeMode.system,
builder: (lightTheme, dartTheme) => MaterialApp(
title: "ente",
@@ -142,8 +118,8 @@ class _AppState extends State<App>
return MaterialApp(
title: "ente",
themeMode: ThemeMode.system,
theme: lightTheme,
darkTheme: darkTheme,
theme: lightThemeData,
darkTheme: darkThemeData,
debugShowCheckedModeBanner: false,
locale: locale,
supportedLocales: appSupportedLocales,

View File

@@ -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'

View File

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

View 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("@"));
}
}

View File

@@ -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"
}

View File

@@ -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

View File

@@ -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';
}

View File

@@ -4,12 +4,14 @@ 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';
import 'package:ente_logging/logging.dart';
import 'package:ente_network/network.dart';
import "package:ente_strings/l10n/strings_localizations.dart";
import "package:ente_ui/theme/ente_theme_data.dart";
import "package:ente_ui/theme/theme_config.dart";
import 'package:ente_ui/utils/window_listener_service.dart';
import 'package:ente_utils/platform_util.dart';
@@ -103,6 +105,8 @@ Future<void> _runInForeground() async {
lockScreen: LockScreen(Configuration.instance),
enabled: await LockScreenSettings.instance.shouldShowLockScreen(),
locale: locale,
lightTheme: lightThemeData,
darkTheme: darkThemeData,
savedThemeMode: savedThemeMode,
supportedLocales: appSupportedLocales,
localizationsDelegates: const [
@@ -166,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,
);
}

View File

@@ -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 {

View File

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

View File

@@ -4,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];
}
}

View File

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

View File

@@ -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);
}

View File

@@ -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;
}

View File

@@ -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),
),
);
}
}

View 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,
);
}
}
}

View 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,
);
}
}

View 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());
},
);
}
}

View 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()),
);
}
}
}

View File

@@ -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,

View File

@@ -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;
}
}
}

View File

@@ -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();
}
}

View File

@@ -1,10 +1,10 @@
import 'dart:async';
import 'dart:io';
import 'dart:math';
import "package:ente_accounts/services/user_service.dart";
import 'package:ente_events/event_bus.dart';
import 'package:ente_ui/components/buttons/gradient_button.dart';
import "package:ente_ui/components/buttons/icon_button_widget.dart";
import 'package:ente_ui/theme/ente_theme.dart';
import 'package:ente_ui/utils/dialog_util.dart';
import 'package:ente_utils/email_util.dart';
@@ -15,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),
];
}
}

View File

@@ -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,

View File

@@ -13,7 +13,7 @@ import "package:ente_lock_screen/ui/lock_screen_options.dart";
import "package:ente_ui/components/captioned_text_widget.dart";
import "package:ente_ui/components/menu_item_widget.dart";
import "package:ente_ui/components/toggle_switch_widget.dart";
import "package:ente_ui/theme/ente_theme.dart";
import "package:ente_ui/theme/ente_theme.dart";
import "package:ente_ui/utils/dialog_util.dart";
import "package:ente_ui/utils/toast_util.dart";
import "package:ente_utils/navigation_util.dart";
@@ -122,7 +122,7 @@ class _SecuritySectionWidgetState extends State<SecuritySectionWidget> {
trailingIcon: Icons.chevron_right_outlined,
trailingIconIsMuted: true,
onTap: () async {
if (await LockScreenSettings.instance.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,
);

View 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;
}
}
}

View 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;
}
}

View 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;
}
}

View 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);
}
},
),
],
),
),
);
}
}

View 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;
}
}
}

View 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);
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -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;
}
}
}

View 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,
),
],
);
}
}
}

View 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;
});
}
},
),
],
),
),
],
),
);
}
}

View File

@@ -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;
}
}

View File

@@ -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"

View File

@@ -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

View File

@@ -1,4 +1,4 @@
# melos_managed_dependency_overrides: ente_accounts,ente_base,ente_configuration,ente_events,ente_lock_screen,ente_logging,ente_network,ente_strings,ente_ui,ente_utils
# 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:

View File

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

View File

@@ -135,6 +135,17 @@
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity-alias>
<activity-alias
android:name="${applicationId}.IconDuckyHuggingE"
android:icon="@mipmap/icon_ducky_hugging_e"
android:enabled="false"
android:exported="true"
android:targetActivity=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity-alias>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_ducky_hugging_e_launcher_foreground"/>
</adaptive-icon>

Binary file not shown.

View File

@@ -0,0 +1,52 @@
<svg width="198" height="150" viewBox="0 0 198 150" fill="none" xmlns="http://www.w3.org/2000/svg">
<path opacity="0.2" d="M103.208 1.51891C70.0589 1.51891 17.0832 -6.28475 9.02179 30.1961C0.957739 66.6797 0.325369 122.498 30.8944 124.356C61.4634 126.213 136.678 126.856 160.318 124.356C183.957 121.855 185.312 83.2462 185.312 57.3881C185.312 31.53 174.179 -0.973365 123.125 0.817466C107.034 1.38341 103.21 1.51891 103.21 1.51891H103.208Z" fill="white"/>
<path d="M141.928 142.014C168.856 142.014 190.684 139.1 190.684 135.504C190.684 131.909 168.856 128.995 141.928 128.995C115.001 128.995 93.1719 131.909 93.1719 135.504C93.1719 139.1 115.001 142.014 141.928 142.014Z" fill="#343434"/>
<path d="M67.2538 149.392C104.397 149.392 134.508 145.779 134.508 141.321C134.508 136.864 104.397 133.251 67.2538 133.251C30.1105 133.251 0 136.864 0 141.321C0 145.779 30.1105 149.392 67.2538 149.392Z" fill="#343434"/>
<path d="M111.039 120.086C101.394 128.801 101.925 135.497 113.988 136.559C126.051 137.622 134.713 135.151 131.923 127.605C129.159 120.086 111.039 120.086 111.039 120.086Z" fill="#FF814A"/>
<path d="M118.989 137.665C117.408 137.665 115.71 137.585 113.909 137.428C108.141 136.921 104.71 135.165 103.722 132.21C102.595 128.852 104.923 124.436 110.452 119.438C110.612 119.294 110.822 119.212 111.037 119.212C111.802 119.212 129.801 119.305 132.739 127.303C133.675 129.829 133.483 131.982 132.173 133.698C130.186 136.299 125.584 137.662 118.989 137.662V137.665ZM111.369 120.963C106.642 125.299 104.514 129.088 105.375 131.657C106.113 133.86 109.118 135.258 114.063 135.691C122.635 136.445 128.733 135.335 130.789 132.643C131.727 131.416 131.828 129.869 131.105 127.908C128.778 121.574 113.803 121.011 111.372 120.963H111.369Z" fill="#1C1C1C"/>
<path d="M157.878 120.086C167.523 128.801 166.992 135.497 154.929 136.559C142.866 137.622 134.204 135.151 136.994 127.605C139.757 120.086 157.878 120.086 157.878 120.086Z" fill="#FF814A"/>
<path d="M149.927 137.665C143.332 137.665 138.73 136.302 136.743 133.701C135.433 131.984 135.241 129.832 136.177 127.303C139.115 119.305 157.114 119.215 157.879 119.215C158.094 119.215 158.304 119.295 158.464 119.441C163.993 124.436 166.321 128.852 165.194 132.213C164.203 135.168 160.775 136.921 155.007 137.431C153.205 137.591 151.508 137.668 149.927 137.668V137.665ZM157.547 120.963C155.116 121.011 140.141 121.574 137.813 127.906C137.088 129.867 137.192 131.416 138.13 132.641C140.186 135.332 146.281 136.446 154.855 135.688C159.8 135.253 162.803 133.858 163.544 131.655C164.405 129.088 162.277 125.297 157.55 120.961L157.547 120.963Z" fill="#1C1C1C"/>
<path d="M100.616 46.8054C80.2899 35.6035 65.3176 38.7095 71.7396 52.8316L100.616 46.8054Z" fill="#F4D93B"/>
<path d="M71.743 53.7029C71.4108 53.7029 71.0946 53.5142 70.9485 53.1927C68.0231 46.7628 69.6599 43.3299 71.5463 41.5842C76.3981 37.0912 87.9747 38.8422 101.039 46.0427C101.462 46.2739 101.616 46.8053 101.382 47.2277C101.148 47.6502 100.619 47.8016 100.197 47.5705C86.1015 39.8014 76.2466 39.6101 72.7314 42.8649C70.077 45.3227 71.1611 49.4463 72.5374 52.4727C72.7367 52.9111 72.5427 53.4292 72.1043 53.6285C71.9874 53.6816 71.8652 53.7082 71.743 53.7082V53.7029Z" fill="#232323"/>
<path d="M174.283 115.362C168.265 123.375 156.484 129.659 133.976 129.659C131.848 129.659 129.815 129.604 127.876 129.495C123.46 129.247 119.517 128.729 116.001 127.985C101.956 125.015 94.7002 118.444 91.084 111.161C86.2907 101.506 87.8929 90.6011 88.5518 85.1861C89.6146 76.4552 90.7253 70.2882 91.7456 64.4747C92.0059 62.9814 92.261 61.5147 92.5081 60.0321C93.1299 56.3043 93.903 52.8635 94.8117 49.6883C101.154 27.4916 114.091 18.3488 127.926 16.3932C129.93 16.1089 131.952 15.9761 133.976 15.9761C138.254 15.9761 142.59 16.5022 146.775 17.7696C160.206 21.8375 172.086 33.5469 175.444 60.0321C176.372 67.3389 178.069 74.2153 179.403 85.1861C180.161 91.4062 182.161 104.867 174.283 115.362Z" fill="#F4D93B"/>
<path d="M129.171 17.7694C129.171 17.7694 107.25 19.5257 97.8895 49.7174L95.2936 51.1947L94.8047 49.6882C101.147 27.4914 114.084 18.3486 127.919 16.3931L129.171 17.7694Z" fill="white"/>
<path d="M174.284 115.362C175.424 110.515 177.432 104.298 176.872 97.9079C174.932 75.8546 175.277 30.1539 144.047 18.8191L146.776 17.7695C160.207 21.8374 172.086 33.5469 175.445 60.0321C176.372 67.3389 178.07 74.2152 179.404 85.1861C180.161 91.4062 182.162 104.867 174.284 115.362Z" fill="white"/>
<path d="M121.074 101.628C120.253 107.898 118.297 117.926 116.001 127.986C101.956 125.015 94.7002 118.444 91.084 111.161C86.2907 101.506 87.8929 90.6013 88.5518 85.1863C89.6146 76.4553 90.7253 70.2884 91.7456 64.4748L94.368 54.9733C137.813 54.333 124.794 73.1978 121.074 101.628Z" fill="#E2B639"/>
<path d="M133.974 130.53C115.279 130.53 101.954 126.178 94.3657 117.596C84.9758 106.973 86.7453 92.6493 87.5956 85.7676L87.6806 85.0795C88.685 76.8215 89.7132 70.9734 90.707 65.3166C91.0338 63.454 91.342 61.6951 91.6449 59.8883C94.2674 44.1561 99.7674 32.343 107.996 24.7784C114.979 18.3564 123.72 15.1016 133.974 15.1016C144.227 15.1016 153.445 18.205 160.382 24.3214C168.906 31.8381 174.265 43.816 176.306 59.9228C176.707 63.0953 177.267 66.2518 177.863 69.5944C178.636 73.9492 179.513 78.8833 180.264 85.0795L180.349 85.7676C181.2 92.652 182.972 106.973 173.579 117.596C165.994 126.178 152.666 130.53 133.971 130.53H133.974ZM133.974 16.8472C124.172 16.8472 115.829 19.948 109.176 26.0644C101.242 33.3606 95.9227 44.8363 93.364 60.1779C93.0611 61.9926 92.7529 63.7542 92.4234 65.6195C91.4323 71.2523 90.4094 77.0792 89.4103 85.292L89.3253 85.9855C88.5069 92.6148 86.8011 106.41 95.6703 116.446C102.911 124.637 115.797 128.79 133.971 128.79C152.145 128.79 165.032 124.637 172.272 116.446C181.141 106.41 179.438 92.6148 178.617 85.9855L178.532 85.2947C177.785 79.1437 176.914 74.2335 176.143 69.9026C175.546 66.5388 174.982 63.361 174.573 60.146C170.035 24.3613 149.988 16.8499 133.971 16.8499L133.974 16.8472Z" fill="#1C1C1C"/>
<path d="M135.676 20.3044L126.961 19.5631C127.338 17.4136 127.864 15.5564 128.483 13.994C131.411 6.59157 136.401 5.82901 137.307 12.1208C137.307 12.1208 140.036 6.16113 147.21 7.88022C149.187 8.35583 150.143 9.60198 150.228 11.1324C150.454 15.1312 144.726 21.0776 135.676 20.3044Z" fill="#F4D93B"/>
<path d="M150.229 11.1324C147.761 9.91816 140.659 9.25922 138.754 11.2706C136.508 13.6406 137.282 12.0863 136.928 11.244C136.575 10.3991 130.108 11.0235 127.969 18.3914L128.484 13.994C131.412 6.59157 136.402 5.82901 137.308 12.1208C137.308 12.1208 140.037 6.16113 147.211 7.88022C149.188 8.35583 150.144 9.60198 150.229 11.1324Z" fill="white"/>
<path d="M126.453 19.475C126.299 15.4656 128.345 9.79818 131.853 7.54768C133.524 6.48222 135.604 6.64961 136.824 8.29697C137.621 9.36243 137.95 10.768 138.043 12.0115L136.619 11.8069C136.725 11.581 136.816 11.4216 136.922 11.2409C138.968 7.76025 142.871 6.13681 146.796 6.95251C152.516 7.97281 151.823 13.4516 148.488 16.7782C146.277 19.0287 143.31 20.4236 140.225 20.8966C138.692 21.1251 137.129 21.1011 135.623 20.8514C135.32 20.8009 135.115 20.5166 135.166 20.2137C135.211 19.9427 135.445 19.7514 135.71 19.7487C137.156 19.7434 138.58 19.6371 139.967 19.3714C142.704 18.8586 145.371 17.6523 147.308 15.641C148.873 14.0547 150.592 10.8185 148.26 9.20301C147.771 8.86557 147.075 8.67958 146.487 8.56533C145.812 8.43779 145.13 8.37136 144.457 8.38996C141.747 8.40591 139.242 10.0665 137.982 12.4286C137.663 13.0769 136.678 12.9255 136.558 12.224C136.21 10.6936 135.341 8.16146 133.375 9.0861C129.985 11.0231 128.956 16.2149 127.45 19.6478C127.253 20.1606 126.461 20.025 126.451 19.4724L126.453 19.475Z" fill="#1C1C1C"/>
<path d="M121.04 44.7142C127.356 40.8907 130.499 40.6516 136.932 44.8629C138.415 45.8407 137.939 47.3233 136.634 47.8574C133.52 49.1036 124.715 49.1912 121.216 48.0062C119.614 47.4429 119.319 45.7823 121.038 44.7142H121.04Z" fill="#FF814A"/>
<path d="M120.694 44.1455C122.944 42.8516 125.41 41.5815 128.054 41.2759C130.761 40.9624 133.434 42.0545 135.708 43.3405C136.096 43.545 136.466 43.7762 136.838 43.9994C137.263 44.2545 137.698 44.4989 138.031 44.9054C139.064 46.048 138.522 47.7644 137.22 48.4313C136.078 48.984 134.784 49.2045 133.546 49.3905C129.626 49.8581 125.622 49.9166 121.764 48.976C121.345 48.8352 120.744 48.6917 120.367 48.4286C118.541 47.2941 118.956 45.11 120.694 44.1455ZM121.39 45.2827C120.51 45.8274 120.205 46.8052 121.334 47.233C123.162 47.7963 125.115 47.8574 127.068 47.9504C129.967 48.0035 132.908 47.9902 135.738 47.3286C135.868 47.2808 136.378 47.1453 136.495 47.0736C137.446 46.5714 137.202 45.7982 136.365 45.3173C135.929 45.033 135.491 44.7248 135.044 44.4644C132.937 43.1731 130.636 42.1288 128.16 42.3706C125.67 42.6576 123.502 43.9675 121.387 45.2827H121.39Z" fill="#232323"/>
<path d="M111.373 54.4653L109.935 65.7577L100.806 137.407C100.806 142.152 83.2321 146 61.5535 146C60.5518 146 59.5607 145.992 58.5776 145.976C44.0969 145.737 31.8108 143.781 25.9601 141.036C23.6112 139.934 22.3013 138.703 22.3013 137.407L11.7344 54.4653H111.373Z" fill="#9F9DA8"/>
<path d="M109.936 65.7583L100.806 137.408C100.806 142.153 83.2326 146 61.554 146C60.5523 146 59.5612 145.992 58.5781 145.976C58.703 88.4253 69.0175 62.1793 109.936 65.7583Z" fill="#8A8899"/>
<path d="M25.9601 141.036C23.6112 139.934 22.3013 138.703 22.3013 137.407L11.7344 54.4653H12.5766L15.5126 57.4545L25.9601 141.036Z" fill="white"/>
<path d="M61.5596 146.871C51.0139 146.871 41.0899 145.97 33.6183 144.334C25.5915 142.577 21.4944 140.266 21.4359 137.468L10.8743 54.5742C10.8424 54.3244 10.9194 54.0747 11.0842 53.8887C11.2489 53.7027 11.488 53.5938 11.7378 53.5938H111.376C111.626 53.5938 111.865 53.7027 112.03 53.8887C112.194 54.0747 112.271 54.3271 112.24 54.5742L101.678 137.468C101.619 140.268 97.5223 142.577 89.4955 144.334C82.0213 145.97 72.1 146.871 61.5542 146.871H61.5596ZM12.7315 55.3368L23.1736 137.298C23.1789 137.335 23.1816 137.372 23.1816 137.407C23.1816 137.885 23.7023 140.377 33.993 142.63C41.3476 144.241 51.1387 145.125 61.5622 145.125C71.9857 145.125 81.7768 144.238 89.1314 142.63C99.4221 140.377 99.9429 137.885 99.9429 137.407C99.9429 137.37 99.9429 137.332 99.9508 137.298L110.393 55.3368H12.7342H12.7315Z" fill="#1C1C1C"/>
<path d="M111.373 54.4654C111.373 57.6273 101.868 60.3986 87.6135 61.9396C80.0331 62.7606 71.1081 63.2336 61.5535 63.2336C47.0169 63.2336 33.9364 62.1389 24.8282 60.3906C16.6977 58.8283 11.7344 56.7505 11.7344 54.4654C11.7344 51.2451 21.5919 48.4313 36.2826 46.9062C43.6903 46.1383 52.3283 45.6973 61.5535 45.6973C69.3014 45.6973 76.6374 46.0081 83.1763 46.5634C99.8597 47.9796 111.373 50.9847 111.373 54.4654Z" fill="#232323"/>
<path d="M61.5578 64.1055C47.5792 64.1055 34.1321 63.064 24.6677 61.2466C15.509 59.4876 10.8672 57.2052 10.8672 54.4658C10.8672 52.4306 13.2824 50.7141 18.2511 49.2156C22.5979 47.9057 28.8021 46.8057 36.1966 46.0404C43.8567 45.246 52.6249 44.8262 61.5578 44.8262C69.1622 44.8262 76.461 45.1184 83.255 45.695C91.6273 46.4071 98.7215 47.5151 103.767 48.8994C109.475 50.467 112.251 52.2871 112.251 54.4658C112.251 56.4692 109.913 58.1618 105.101 59.6444C100.903 60.9383 94.8901 62.0304 87.7135 62.8062C79.8673 63.6565 70.8228 64.1055 61.5605 64.1055H61.5578ZM61.5578 46.5692C52.686 46.5692 43.979 46.9863 36.3772 47.7728C17.5788 49.7257 12.6102 53.0045 12.6102 54.4658C12.6102 55.7067 15.8544 57.7791 24.9972 59.5354C34.3579 61.3316 47.6829 62.3625 61.5578 62.3625C70.7591 62.3625 79.7371 61.9161 87.5249 61.0738C105.704 59.1077 110.505 55.8927 110.505 54.4658C110.505 52.574 103.209 49.1385 83.1062 47.4327C76.3627 46.8588 69.1117 46.5692 61.5578 46.5692Z" fill="#232323"/>
<path d="M65.278 73.9174C50.4545 73.9174 32.1025 71.7679 12.7567 64.3973C12.483 64.2937 12.3449 63.9855 12.4485 63.7118C12.5521 63.4381 12.8603 63.3 13.134 63.4036C39.8503 73.5826 64.6696 73.7288 80.7844 72.0575C98.257 70.2454 109.555 65.9676 109.666 65.9251C109.94 65.8188 110.248 65.957 110.352 66.2306C110.458 66.5043 110.32 66.8125 110.046 66.9162C109.932 66.9587 98.5333 71.279 80.9305 73.1097C76.3578 73.5853 71.0836 73.9147 65.2727 73.9147L65.278 73.9174Z" fill="#1C1C1C"/>
<path d="M59.9036 138.648C48.4173 138.648 34.9515 137.13 21.3981 132.231C21.1217 132.13 20.9782 131.827 21.0792 131.551C21.1802 131.274 21.4831 131.131 21.7594 131.232C42.2317 138.632 62.5234 138.241 75.9413 136.61C90.4965 134.84 100.33 131.269 100.428 131.232C100.705 131.131 101.01 131.272 101.111 131.548C101.212 131.824 101.071 132.13 100.795 132.231C100.697 132.268 90.7702 135.874 76.1008 137.662C71.4351 138.23 65.943 138.65 59.9036 138.65V138.648Z" fill="#1C1C1C"/>
<path d="M28.3625 125.363C27.1482 125.363 26.1093 124.443 25.9844 123.208L21.34 76.6222C21.2098 75.3069 22.1689 74.1352 23.4815 74.005C24.7941 73.8748 25.9658 74.834 26.0987 76.1466L30.7432 122.732C30.8733 124.047 29.9142 125.219 28.6016 125.349C28.5219 125.357 28.4422 125.363 28.3625 125.363Z" fill="#232323"/>
<path d="M43.4128 127.828C42.1215 127.828 41.056 126.797 41.0242 125.498L39.8524 80.3419C39.8179 79.0214 40.8594 77.924 42.1799 77.8895C43.4952 77.855 44.5978 78.8965 44.6324 80.2171L45.8041 125.373C45.8387 126.694 44.7971 127.791 43.4766 127.826C43.4553 127.826 43.4341 127.826 43.4128 127.826V127.828Z" fill="#232323"/>
<path d="M60.6807 128.995C60.6807 128.995 60.6568 128.995 60.6435 128.995C59.323 128.976 58.2681 127.89 58.2894 126.569L58.9643 81.4077C58.9829 80.0872 60.0749 79.0297 61.3901 79.0536C62.7107 79.0722 63.7655 80.1589 63.7442 81.4795L63.0694 126.641C63.0508 127.948 61.9826 128.998 60.678 128.998L60.6807 128.995Z" fill="#232323"/>
<path d="M93.2992 125.362C93.2195 125.362 93.1398 125.36 93.0601 125.349C91.7449 125.219 90.7857 124.047 90.9185 122.732L95.563 76.1465C95.6932 74.8313 96.8676 73.8721 98.1802 74.005C99.4954 74.1352 100.455 75.3069 100.322 76.6221L95.6773 123.208C95.5551 124.441 94.5135 125.362 93.2992 125.362Z" fill="#232323"/>
<path d="M78.2515 127.828C78.2303 127.828 78.209 127.828 78.1877 127.828C76.8672 127.794 75.8257 126.696 75.8602 125.376L77.0319 80.2199C77.0665 78.9206 78.1293 77.8896 79.4206 77.8896C79.4419 77.8896 79.4631 77.8896 79.4844 77.8896C80.8049 77.9242 81.8465 79.0215 81.8119 80.3421L80.6402 125.498C80.6056 126.797 79.5428 127.828 78.2515 127.828Z" fill="#232323"/>
<path d="M92.3543 60.3824L87.6115 61.9394C80.031 62.7605 71.1061 63.2334 61.5514 63.2334C49.0342 63.2334 37.5984 62.423 28.8462 61.0786C27.4353 60.8633 26.0908 60.6322 24.8261 60.3904L24.7969 60.3824C24.7969 60.3824 24.7995 60.3718 24.7995 60.3665C25.7587 55.1507 30.815 50.5116 36.2805 46.906C40.9622 43.8186 45.9414 41.4963 48.8987 40.2316C50.5381 39.5301 51.5584 39.1528 51.5584 39.1528L74.5708 41.313C78.0409 42.7265 80.8733 44.5731 83.1743 46.5633C90.3801 52.7833 92.3543 60.3824 92.3543 60.3824Z" fill="#20D34F"/>
<path d="M50.7588 41.2815C50.7588 41.2815 31.785 53.0202 28.8411 61.0789C28.6816 61.52 28.5674 61.9478 28.5089 62.3623L24.6562 61.2463L24.821 60.3907L24.7944 60.3668C25.7536 55.1511 30.8099 50.5119 36.2754 46.9064C40.9571 43.8189 45.9363 41.4967 48.8936 40.2319L50.7588 41.2815Z" fill="white"/>
<path d="M61.5571 64.1048C47.5785 64.1048 34.1313 63.0633 24.667 61.2459C24.6457 61.2406 24.6245 61.2379 24.6032 61.2326L24.574 61.2246C24.1356 61.1077 23.8619 60.672 23.9443 60.2256C24.7999 55.5306 28.7907 50.8038 35.8052 46.1779C43.0828 41.3794 51.1814 38.3636 51.2611 38.3344C51.3833 38.2892 51.5135 38.2733 51.6437 38.2839L74.6562 40.4441C74.7412 40.4521 74.8236 40.4733 74.9033 40.5052C78.1687 41.8364 81.1446 43.6511 83.7485 45.9043C91.0765 52.228 93.1198 59.843 93.2022 60.1645C93.3191 60.6109 93.0667 61.0679 92.6309 61.2113L87.8881 62.7684C87.8297 62.787 87.7712 62.8002 87.7101 62.8056C79.8639 63.6558 70.8194 64.1048 61.5571 64.1048ZM25.8547 59.6942C35.1622 61.392 48.1019 62.3618 61.5597 62.3618C70.7264 62.3618 79.67 61.9208 87.4338 61.0838L91.2705 59.8244C90.5584 57.7705 88.191 52.0393 82.6113 47.2248C80.1828 45.1231 77.4088 43.4253 74.3665 42.1711L51.6836 40.0429C50.456 40.5132 43.2715 43.3482 36.7671 47.6366C30.6054 51.7019 26.9388 55.7538 25.8547 59.6968V59.6942Z" fill="#232323"/>
<path d="M73.0661 37.8245L52.6549 36.9291C52.2006 36.201 51.8684 35.5527 51.6399 34.9762C49.8704 30.4938 54.2491 30.2865 54.2491 30.2865C55.676 24.7387 60.1185 25.8094 63.469 27.5764C65.7354 28.772 67.5023 30.2865 67.5023 30.2865C79.2251 24.4411 73.0661 37.8245 73.0661 37.8245Z" fill="#20D34F"/>
<path d="M65.7886 29.5904C65.5229 29.5319 56.7547 27.5631 55.5272 31.9764C55.5272 31.9764 53.0535 32.545 53.9834 36.6209L51.6399 34.9762C49.8704 30.4938 54.2491 30.2865 54.2491 30.2865C55.676 24.7387 60.1185 25.8094 63.469 27.5764L65.7886 29.5904Z" fill="white"/>
<path d="M73.0599 38.696C73.0599 38.696 73.0334 38.696 73.0227 38.696L52.6115 37.8006C52.3245 37.7873 52.0615 37.6358 51.91 37.3914C50.353 34.8991 49.9545 32.8877 50.725 31.4158C51.3972 30.1298 52.755 29.6701 53.5601 29.5054C54.227 27.3904 55.4093 26.0539 57.0833 25.5251C60.9253 24.3135 66.0746 28.0413 67.6183 29.2583C70.9768 27.672 73.2459 27.5152 74.5479 28.7747C75.6479 29.8428 75.8418 31.7957 75.1218 34.5829C74.6302 36.4907 73.8836 38.1194 73.8517 38.1885C73.7082 38.4994 73.4 38.696 73.0599 38.696ZM53.1535 36.0788L72.5019 36.9264C73.5249 34.4899 74.3751 31.0358 73.3336 30.0261C72.6082 29.322 70.6234 29.702 67.8867 31.065C67.5732 31.2218 67.1959 31.174 66.9302 30.9481C65.336 29.5851 60.5533 26.2558 57.604 27.1884C56.3845 27.5737 55.5608 28.6578 55.0852 30.5044C54.9895 30.8737 54.6654 31.1394 54.2828 31.158C54.2721 31.158 52.7683 31.2563 52.2634 32.2315C51.8489 33.0312 52.1651 34.389 53.1509 36.0788H53.1535Z" fill="#232323"/>
<path d="M74.6803 37.9618L51.0823 36.4317C49.9841 36.3605 49.036 37.1931 48.9648 38.2913C48.8936 39.3896 49.7262 40.3376 50.8244 40.4089L74.4224 41.939C75.5206 42.0102 76.4687 41.1776 76.5399 40.0794C76.6111 38.9811 75.7785 38.033 74.6803 37.9618Z" fill="#9F9DA8"/>
<path d="M74.5503 42.8173C74.4866 42.8173 74.4254 42.8173 74.3617 42.812L50.7647 41.2816C50.0021 41.2311 49.3033 40.8883 48.7985 40.3144C48.2937 39.7405 48.0439 39.0019 48.0917 38.2393C48.1422 37.4767 48.485 36.7779 49.0589 36.2731C49.6355 35.7683 50.3715 35.5185 51.134 35.5663L74.731 37.0968C75.4936 37.1473 76.1924 37.49 76.6972 38.0639C77.202 38.6378 77.4518 39.3765 77.404 40.1391C77.3535 40.9016 77.0107 41.6004 76.4368 42.1053C75.9107 42.5676 75.2465 42.8173 74.5503 42.8173ZM74.4733 41.0717C74.7735 41.0903 75.0605 40.992 75.2863 40.7953C75.5122 40.5987 75.645 40.3251 75.6663 40.0248C75.6849 39.7246 75.5866 39.4376 75.3899 39.2118C75.1933 38.9859 74.9197 38.8531 74.6194 38.8318L51.0224 37.3014C50.7222 37.2828 50.4352 37.3811 50.2094 37.5777C49.9835 37.7743 49.8507 38.048 49.8294 38.3482C49.8108 38.6485 49.9091 38.9354 50.1057 39.1613C50.3024 39.3871 50.576 39.52 50.8763 39.5412L74.4733 41.0717Z" fill="#232323"/>
<path d="M111.235 40.3833C111.062 40.3833 110.889 40.3541 110.719 40.2877C109.964 40.0034 109.582 39.1584 109.869 38.4038C111.349 34.4874 114.266 33.3449 116.506 33.5813C119.158 33.863 121.299 35.9673 121.714 38.7014C121.836 39.4985 121.286 40.2452 120.489 40.3647C119.692 40.4843 118.945 39.9369 118.826 39.1398C118.61 37.7183 117.532 36.6289 116.201 36.4881C114.705 36.3287 113.36 37.4314 112.606 39.4374C112.385 40.022 111.83 40.3833 111.237 40.3833H111.235Z" fill="#1C1C1C"/>
<path d="M147.634 40.3833C147.044 40.3833 146.489 40.022 146.266 39.4374C145.509 37.434 144.164 36.3314 142.671 36.4881C141.34 36.6289 140.261 37.7183 140.046 39.1398C139.924 39.9369 139.18 40.4869 138.383 40.3647C137.585 40.2425 137.035 39.4985 137.158 38.7014C137.572 35.9673 139.714 33.863 142.365 33.5813C144.603 33.3449 147.523 34.4874 149.003 38.4038C149.287 39.1584 148.907 40.0034 148.152 40.2877C147.982 40.3514 147.807 40.3833 147.637 40.3833H147.634Z" fill="#1C1C1C"/>
<path d="M193.199 114.384C187.58 108.953 173.386 102.09 156.054 96.898C138.723 91.7061 123.094 89.6337 115.415 91.0791L111.839 90.0083L110.51 93.9938C108.879 99.4381 126.625 109.561 150.148 116.61C173.67 123.657 194.06 124.959 195.692 119.515L197.02 115.529L193.197 114.384H193.199Z" fill="#B7B6BF"/>
<path d="M184.999 123.75C183.675 123.75 182.344 123.699 181.072 123.617C172.404 123.064 161.333 120.872 149.897 117.445C138.458 114.017 128.006 109.761 120.462 105.456C114.891 102.278 108.498 97.6658 109.672 93.7414C109.672 93.7334 109.677 93.7255 109.68 93.7148L111.009 89.7293C111.157 89.2829 111.633 89.0332 112.085 89.1687L115.459 90.181C123.571 88.7462 139.55 91.0419 156.3 96.061C173.047 101.077 187.658 107.948 193.645 113.605L197.266 114.689C197.492 114.756 197.681 114.913 197.79 115.122C197.898 115.332 197.917 115.577 197.843 115.8L196.517 119.775C195.563 122.908 190.326 123.744 184.993 123.744L184.999 123.75ZM111.341 94.2569C110.605 96.7784 116.014 100.913 121.328 103.944C128.76 108.185 139.083 112.386 150.399 115.776C161.715 119.166 172.646 121.332 181.186 121.877C187.3 122.267 194.102 121.786 194.859 119.265C194.859 119.257 194.864 119.249 194.867 119.238L195.911 116.106L192.951 115.218C192.818 115.178 192.696 115.106 192.595 115.011C186.893 109.503 172.455 102.722 155.806 97.7323C139.157 92.745 123.369 90.468 115.579 91.9346C115.443 91.9612 115.302 91.9532 115.167 91.9134L112.401 91.0844L111.343 94.2542L111.341 94.2569Z" fill="#232323"/>
<path d="M197.02 115.529C195.981 119.004 187.295 119.73 175.033 117.968C168.085 116.972 159.984 115.173 151.476 112.625C127.956 105.578 110.207 95.4524 111.838 90.0082C113.47 84.5639 133.86 85.8659 157.382 92.9123C164.179 94.9502 170.492 97.2432 175.997 99.6053C189.543 105.422 198.181 111.66 197.02 115.529Z" fill="#9F9DA8"/>
<path d="M197.017 115.529C195.979 119.004 187.293 119.73 175.031 117.968C175.326 117.979 175.636 117.995 175.961 118.011C200.565 119.249 197.589 109.599 173.492 99.4966L175.995 99.6055C189.541 105.422 198.179 111.66 197.017 115.529Z" fill="white"/>
<path d="M186.335 119.764C185.011 119.764 183.68 119.714 182.408 119.631C173.74 119.078 162.668 116.886 151.233 113.459C139.794 110.031 129.342 105.775 121.798 101.47C116.226 98.2926 109.834 93.68 111.008 89.7556C112.182 85.8338 120.063 85.4964 126.461 85.9029C135.128 86.4556 146.2 88.6476 157.636 92.0752C178.688 98.383 199.939 108.833 197.858 115.776C196.917 118.919 191.67 119.761 186.332 119.761L186.335 119.764ZM122.696 87.5264C117.783 87.5264 113.282 88.2358 112.677 90.2578C111.922 92.7793 117.34 96.9216 122.659 99.9559C130.091 104.197 140.413 108.397 151.73 111.788C163.043 115.178 173.977 117.343 182.516 117.888C188.63 118.279 195.432 117.798 196.189 115.276C196.944 112.755 191.526 108.612 186.207 105.578C178.775 101.338 168.453 97.1368 157.137 93.7465C145.82 90.3561 134.887 88.1906 126.35 87.6459C125.141 87.5689 123.905 87.5264 122.696 87.5264Z" fill="#232323"/>
<path d="M164.6 106.976C164.412 106.976 164.22 106.949 164.029 106.894C162.974 106.58 162.374 105.469 162.69 104.415C163.928 100.264 161.157 98.4788 154.99 95.6544C154.06 95.2293 153.098 94.7882 152.203 94.3339C149.086 92.7503 146.803 92.2773 145.228 92.8964C143.897 93.4172 142.791 94.8467 141.941 97.1423C141.558 98.1732 140.413 98.702 139.38 98.3194C138.349 97.9368 137.82 96.7916 138.203 95.758C139.465 92.3491 141.341 90.1358 143.777 89.1846C147.369 87.779 151.339 89.4237 154.009 90.7814C154.833 91.1986 155.715 91.6051 156.65 92.0329C161.808 94.395 168.87 97.6312 166.508 105.554C166.25 106.418 165.458 106.979 164.6 106.979V106.976Z" fill="#232323"/>
<path d="M153.389 70.5537C139.259 91.3847 144.509 114.857 171.688 90.1492L153.389 70.5537Z" fill="#F4D93B"/>
<path d="M152.855 101.628C150.517 101.628 148.944 100.804 147.929 99.922C145.285 97.629 144.331 93.2529 145.243 87.6014C146.144 82.0243 148.779 75.7962 152.667 70.0624C152.938 69.6638 153.48 69.5602 153.878 69.8312C154.277 70.1022 154.38 70.6443 154.109 71.0428C150.36 76.5694 147.823 82.5477 146.962 87.8804C146.162 92.841 146.93 96.7495 149.069 98.6067C150.918 100.212 156.766 102.534 171.098 89.5065C171.454 89.1823 172.007 89.2089 172.328 89.5649C172.653 89.921 172.626 90.4736 172.27 90.7978C162.957 99.263 156.811 101.63 152.852 101.63L152.855 101.628Z" fill="#232323"/>
</svg>

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

View File

@@ -1,4 +1,4 @@
ente es una aplicación simple para hacer copias de seguridad y compartir tus fotos y videos.
ente es una aplicación simple para hacer copias de seguridad y compartir tus fotos y vídeos.
Si has estado buscando una alternativa a Google Photos que sea amigable con la privacidad, has llegado al lugar correcto. Con Ente, se almacenan cifradas de extremo a extremo (e2ee). Esto significa que solo tú puedes verlas.

View File

@@ -6,23 +6,23 @@ Kami menyediakan app untuk Android, iOS, web, serta desktop, dan fotomu akan ter
ente juga dapat memudahkan kamu untuk membagikan album ke orang tersayang, meski mereka tidak punya akun ente. Kamu dapat membagikan link berbagi publik, di mana mereka bisa melihat album kamu dan berkolaborasi dengan menambahkan foto, tanpa akun atau app.
Data terenkripsi kamu tersimpan di 3 lokasi berbeda, termasuk di salah satu tempat pengungsian di Paris. We take posterity seriously and make it easy to ensure that your memories outlive you.
Data terenkripsi kamu tersimpan di 3 lokasi berbeda, termasuk di salah satu tempat pengungsian di Paris. Kami menanggapi keturunan anda dengan serius dan memudahkan untuk memastikan kenangan anda tetap ada setelah anda.
Kami ingin membuat app foto yang paling aman sepanjang masajadi, bergabunglah dengan kami!
FITUR
- Pencadangan kualitas asli, karena setiap piksel berarti
- Paket keluarga, sehingga kamu bisa bagikan kuota penyimpananmu dengan keluarga
- Collaborative albums, so you can pool together photos after a trip
- Shared folders, in case you want your partner to enjoy your "Camera" clicks
- Album kolaboratif, sehingga anda dapat mengumpulkan foto bersama setelah sebuah perjalanan
- Folder bersama, jika anda ingin pasangan anda menikmati hasil jepretan "Kamera" anda
- Link album, yang bisa dilindungi dengan sandi
Kemampuan untuk membebaskan kapasitas, dengan menghilangkan files yang sudah di back-up dengan aman
- Human support, because you're worth it
- Descriptions, so you can caption your memories and find them easily
- Dukungan manusia, karena anda layak mendapatkannya
- Deskripsi, sehingga anda dapat memberi keterangan pada memori anda dan menemukannya dengan mudah
- Editor gambar, untuk menyempurnakan fotomu
- Favorite, hide and relive your memories, for they are precious
- Favoritkan, sembunyikan, dan kenang kembali memori anda, karena itu sangat berharga
- Pengimporan mudah dari Google, Apple, hard drive-mu, dan lainnya
- Dark theme, because your photos look good in it
- Tema gelap, karena foto anda terlihat bagus di dalamnya
- Autentikasi dua atau tiga faktor dan autentikasi biometrik
- dan BANYAK LAGI!

View File

@@ -6,20 +6,20 @@ Kami menyediakan app untuk semua platform, dan fotomu akan tersinkron di semua p
Ente juga memudahkan kamu untuk membagikan album ke kerabatmu. Kamu bisa membagikannya secara langsung ke pengguna Ente lain, terenkripsi ujung ke ujung; atau dengan link yang dapat dilihat publik.
Data terenkripsi kamu tersimpan di berbagai lokasi, termasuk di salah satu tempat pengungsian di Paris. We take posterity seriously and make it easy to ensure that your memories outlive you.
Data terenkripsi kamu tersimpan di berbagai lokasi, termasuk di salah satu tempat pengungsian di Paris. Kami menanggapi keturunan anda dengan serius dan memudahkan untuk memastikan kenangan anda tetap ada setelah anda.
Kami ingin membuat app foto yang paling aman sepanjang masajadi, bergabunglah dengan kami!
FITUR
- Pencadangan kualitas asli, karena setiap piksel berarti
- Paket keluarga, sehingga kamu bisa bagikan kuota penyimpananmu dengan keluarga
- Shared folders, in case you want your partner to enjoy your "Camera" clicks
- Folder bersama, jika anda ingin pasangan anda menikmati hasil jepretan "Kamera" anda
- Link album, yang bisa dilindungi dengan sandi dan diatur waktu kedaluwarsanya
- Ability to free up space, by removing files that have been safely backed up
- Kemampuan untuk mengosongkan ruang, dengan menghapus file yang telah dicadangkan dengan aman
- Editor gambar, untuk menyempurnakan fotomu
- Favorite, hide and relive your memories, for they are precious
- One-click import from all major storage providers
- Dark theme, because your photos look good in it
Favoritkan, sembunyikan, dan kenang kembali memori anda, karena itu sangat berharga
Impor sekali klik dari semua penyedia penyimpanan utama
Tema gelap, karena foto anda terlihat bagus di dalamnya
- Autentikasi dua atau tiga faktor dan autentikasi biometrik
- dan BANYAK LAGI!
@@ -27,7 +27,7 @@ HARGA
Kami tidak menyediakan paket yang gratis seumur hidup, karena penting bagi kami untuk tetap berdiri dan bertahan hingga masa depan. Namun, kami menyediakan paket yang terjangkau, yang bisa kamu bagikan dengan keluargamu. Kamu bisa menemukan informasi lebih lanjut di ente.io.
DUKUNGAN
We take pride in offering human support. Jika kamu adalah pelanggan berbayar, kamu bisa menghubungi team@ente.io dan menunggu balasan dari tim kami dalam 24 jam.
Kami bangga dapat menawarkan dukungan manusia. Jika kamu adalah pelanggan berbayar, kamu bisa menghubungi team@ente.io dan menunggu balasan dari tim kami dalam 24 jam.
KETENTUAN
https://ente.io/terms

View File

@@ -6,20 +6,20 @@ Kami menyediakan app untuk Android, iOS, Web, serta Desktop, dan fotomu akan ter
Ente juga memudahkan kamu untuk membagikan album ke kerabatmu. Kamu bisa membagikannya secara langsung ke pengguna Ente lain, terenkripsi ujung ke ujung; atau dengan link yang dapat dilihat publik.
Data terenkripsi kamu tersimpan di berbagai lokasi, termasuk di salah satu tempat pengungsian di Paris. We take posterity seriously and make it easy to ensure that your memories outlive you.
Data terenkripsi kamu tersimpan di berbagai lokasi, termasuk di salah satu tempat pengungsian di Paris. Kami menanggapi keturunan anda dengan serius dan memudahkan untuk memastikan kenangan anda tetap ada setelah anda.
Kami ingin membuat app foto yang paling aman sepanjang masajadi, bergabunglah dengan kami!
✨ FITUR
- Pencadangan kualitas asli, karena setiap piksel berarti
- Paket keluarga, sehingga kamu bisa bagikan kuota penyimpananmu dengan keluarga
- Shared folders, in case you want your partner to enjoy your "Camera" clicks
- Folder bersama, jika anda ingin pasangan anda menikmati hasil jepretan "Kamera" anda
- Link album, yang bisa dilindungi dengan sandi dan diatur waktu kedaluwarsanya
- Ability to free up space, by removing files that have been safely backed up
- Kemampuan untuk mengosongkan ruang, dengan menghapus file yang telah dicadangkan dengan aman
- Editor gambar, untuk menyempurnakan fotomu
- Favorite, hide and relive your memories, for they are precious
- Favoritkan, sembunyikan, dan kenang kembali memori anda, karena itu sangat berharga
- Pengimporan mudah dari Google, Apple, hard drive-mu, dan lainnya
- Dark theme, because your photos look good in it
- Tema gelap, karena foto anda terlihat bagus di dalamnya
- Autentikasi dua atau tiga faktor dan autentikasi biometrik
- dan BANYAK LAGI!
@@ -27,4 +27,4 @@ Kami ingin membuat app foto yang paling aman sepanjang masajadi, bergabunglah
Kami tidak menyediakan paket yang gratis seumur hidup, karena penting bagi kami untuk tetap berdiri dan bertahan hingga masa depan. Namun, kami menyediakan paket yang terjangkau, yang bisa kamu bagikan dengan keluargamu. Kamu bisa menemukan informasi lebih lanjut di ente.io.
🙋 DUKUNGAN
We take pride in offering human support. Jika kamu adalah pelanggan berbayar, kamu bisa menghubungi team@ente.io dan menunggu balasan dari tim kami dalam 24 jam.
Kami bangga dapat menawarkan dukungan manusia. Jika kamu adalah pelanggan berbayar, kamu bisa menghubungi team@ente.io dan menunggu balasan dari tim kami dalam 24 jam.

View File

@@ -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

View File

@@ -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",

View File

@@ -0,0 +1,38 @@
{
"images" : [
{
"filename" : "IconDuckyHuggingEAny.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "IconDuckyHuggingEDark.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "tinted"
}
],
"filename" : "IconDuckyHuggingETinted.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

View File

@@ -142,7 +142,7 @@ class _EnteAppState extends State<EnteApp> with WidgetsBindingObserver {
debugShowCheckedModeBanner: false,
builder: EasyLoading.init(),
locale: locale,
supportedLocales: AppLocalizations.supportedLocales,
supportedLocales: appSupportedLocales,
localeListResolutionCallback: localResolutionCallBack,
localizationsDelegates: const [
...AppLocalizations.localizationsDelegates,
@@ -164,7 +164,7 @@ class _EnteAppState extends State<EnteApp> with WidgetsBindingObserver {
debugShowCheckedModeBanner: false,
builder: EasyLoading.init(),
locale: locale,
supportedLocales: AppLocalizations.supportedLocales,
supportedLocales: appSupportedLocales,
localeListResolutionCallback: localResolutionCallBack,
localizationsDelegates: const [
...AppLocalizations.localizationsDelegates,

View File

@@ -28,6 +28,7 @@ import 'package:photos/services/favorites_service.dart';
import "package:photos/services/home_widget_service.dart";
import 'package:photos/services/ignored_files_service.dart';
import "package:photos/services/machine_learning/face_ml/person/person_service.dart";
import "package:photos/services/machine_learning/similar_images_service.dart";
import 'package:photos/services/search_service.dart';
import 'package:photos/services/sync/sync_service.dart';
import 'package:photos/utils/file_uploader.dart';
@@ -196,6 +197,7 @@ class Configuration {
await CollectionsDB.instance.clearTable();
await MemoriesDB.instance.clearTable();
await MLDataDB.instance.clearTable();
await SimilarImagesService.instance.clearCache();
await UploadLocksDB.instance.clearTable();
await IgnoredFilesService.instance.reset();

View File

@@ -0,0 +1,13 @@
// 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'
: 'WidgetUnmountedException';
}

View File

@@ -1,3 +1,4 @@
import "dart:io" show File;
import "dart:typed_data" show Float32List;
import "package:flutter_rust_bridge/flutter_rust_bridge.dart" show Uint64List;
@@ -12,8 +13,8 @@ import "package:shared_preferences/shared_preferences.dart";
class ClipVectorDB {
static final Logger _logger = Logger("ClipVectorDB");
static const _databaseName = "ente.ml.vectordb.clip";
static const _kMigrationKey = "clip_vector_migration";
static const _databaseName = "ente.ml.vectordb.clip.usearch";
static const _kMigrationKey = "clip_vectordb_migration";
static final BigInt _embeddingDimension = BigInt.from(512);
@@ -36,13 +37,28 @@ class ClipVectorDB {
Future<VectorDb> _initVectorDB() async {
final documentsDirectory = await getApplicationDocumentsDirectory();
final String databaseDirectory =
join(documentsDirectory.path, _databaseName);
_logger.info("Opening vectorDB access: DB path " + databaseDirectory);
final vectorDB = VectorDb(
filePath: databaseDirectory,
dimensions: _embeddingDimension,
);
final String dbPath = join(documentsDirectory.path, _databaseName);
_logger.info("Opening vectorDB access: DB path " + dbPath);
late VectorDb vectorDB;
try {
vectorDB = VectorDb(
filePath: dbPath,
dimensions: _embeddingDimension,
);
} catch (e, s) {
_logger.severe("Could not open VectorDB at path $dbPath", e, s);
_logger.severe("Deleting the index file and trying again");
await deleteIndexFile();
try {
vectorDB = VectorDb(
filePath: dbPath,
dimensions: _embeddingDimension,
);
} catch (e, s) {
_logger.severe("Still can't open VectorDB at path $dbPath", e, s);
rethrow;
}
}
final stats = await getIndexStats(vectorDB);
_logger.info("VectorDB connection opened with stats: ${stats.toString()}");
@@ -141,17 +157,6 @@ class ClipVectorDB {
}
}
Future<void> deleteIndex() async {
final db = await _vectorDB;
try {
await db.deleteIndex();
_vectorDbFuture = null;
} catch (e, s) {
_logger.severe("Error deleting index", e, s);
rethrow;
}
}
Future<VectorDbStats> getIndexStats([VectorDb? db]) async {
db ??= await _vectorDB;
try {
@@ -278,6 +283,40 @@ class ClipVectorDB {
rethrow;
}
}
Future<void> deleteIndex() async {
final db = await _vectorDB;
try {
await db.deleteIndex();
_vectorDbFuture = null;
} catch (e, s) {
_logger.severe("Error deleting index", e, s);
rethrow;
}
}
Future<void> deleteIndexFile({bool undoMigration = false}) async {
try {
final documentsDirectory = await getApplicationDocumentsDirectory();
final String dbPath = join(documentsDirectory.path, _databaseName);
_logger.info("Delete index file: DB path " + dbPath);
final file = File(dbPath);
if (await file.exists()) {
await file.delete();
}
_logger.info("Deleted index file on disk");
_vectorDbFuture = null;
if (undoMigration) {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_kMigrationKey, false);
_migrationDone = false;
_logger.info("Undid migration flag");
}
} catch (e, s) {
_logger.severe("Error deleting index file on disk", e, s);
rethrow;
}
}
}
class VectorDbStats {

View File

@@ -260,6 +260,9 @@ class MLDataDB with SqlDbBase implements IMLDataDB<int> {
await db.execute(deleteNotPersonFeedbackTable);
await db.execute(deleteClipEmbeddingsTable);
await db.execute(deleteFileDataTable);
if (await ClipVectorDB.instance.checkIfMigrationDone()) {
await ClipVectorDB.instance.deleteIndexFile();
}
}
@override
@@ -1289,8 +1292,11 @@ class MLDataDB with SqlDbBase implements IMLDataDB<int> {
int processedCount = 0;
int weirdCount = 0;
int whileCount = 0;
const String migrationKey = "clip_vector_db_migration_in_progress";
final stopwatch = Stopwatch()..start();
try {
// Make sure no other heavy compute is running
computeController.blockCompute(blocker: migrationKey);
while (true) {
whileCount++;
_logger.info("$whileCount st round of while loop");
@@ -1320,6 +1326,9 @@ class MLDataDB with SqlDbBase implements IMLDataDB<int> {
embeddings.add(Float32List.view(result[embeddingColumn].buffer));
} else {
weirdCount++;
_logger.warning(
"Weird clip embedding length ${embedding.length} for fileID ${result[fileIDColumn]}, skipping",
);
}
}
_logger.info(
@@ -1346,7 +1355,7 @@ class MLDataDB with SqlDbBase implements IMLDataDB<int> {
"migrated all $totalCount embeddings to ClipVectorDB in ${stopwatch.elapsed.inMilliseconds} ms, with $weirdCount weird embeddings not migrated",
);
await ClipVectorDB.instance.setMigrationDone();
_logger.info("ClipVectorDB migration done, flag file created");
_logger.info("ClipVectorDB migration done");
} catch (e, s) {
_logger.severe(
"Error migrating ClipVectorDB after ${stopwatch.elapsed.inMilliseconds} ms, clearing out DB again",
@@ -1357,6 +1366,8 @@ class MLDataDB with SqlDbBase implements IMLDataDB<int> {
rethrow;
} finally {
stopwatch.stop();
// Make sure compute can run again
computeController.unblockCompute(blocker: migrationKey);
}
}

View File

@@ -66,7 +66,6 @@ class UploadLocksDB {
static final migrationScripts = [
..._createTrackUploadsTable(),
..._createStreamQueueTable(),
];
final dbConfig = MigrationConfig(
@@ -142,11 +141,6 @@ class UploadLocksDB {
${_streamUploadErrorTable.columnCreatedAt} INTEGER DEFAULT CURRENT_TIMESTAMP NOT NULL
)
''',
];
}
static List<String> _createStreamQueueTable() {
return [
'''
CREATE TABLE IF NOT EXISTS ${_streamQueueTable.table} (
${_streamQueueTable.columnUploadedFileID} INTEGER PRIMARY KEY,
@@ -158,7 +152,7 @@ class UploadLocksDB {
}
Future<void> clearTable() async {
final db = await instance.database;
final db = await database;
await db.delete(_uploadLocksTable.table);
await db.delete(_trackUploadTable.table);
await db.delete(_partsTable.table);
@@ -166,7 +160,7 @@ class UploadLocksDB {
}
Future<void> acquireLock(String id, String owner, int time) async {
final db = await instance.database;
final db = await database;
final row = <String, dynamic>{};
row[_uploadLocksTable.columnID] = id;
row[_uploadLocksTable.columnOwner] = owner;
@@ -179,7 +173,7 @@ class UploadLocksDB {
}
Future<String> getLockData(String id) async {
final db = await instance.database;
final db = await database;
final rows = await db.query(
_uploadLocksTable.table,
where: '${_uploadLocksTable.columnID} = ?',
@@ -196,7 +190,7 @@ class UploadLocksDB {
}
Future<bool> isLocked(String id, String owner) async {
final db = await instance.database;
final db = await database;
final rows = await db.query(
_uploadLocksTable.table,
where:
@@ -207,7 +201,7 @@ class UploadLocksDB {
}
Future<int> releaseLock(String id, String owner) async {
final db = await instance.database;
final db = await database;
return db.delete(
_uploadLocksTable.table,
where:
@@ -217,7 +211,7 @@ class UploadLocksDB {
}
Future<int> releaseLocksAcquiredByOwnerBefore(String owner, int time) async {
final db = await instance.database;
final db = await database;
return db.delete(
_uploadLocksTable.table,
where:
@@ -227,7 +221,7 @@ class UploadLocksDB {
}
Future<int> releaseAllLocksAcquiredBefore(int time) async {
final db = await instance.database;
final db = await database;
return db.delete(
_uploadLocksTable.table,
where: '${_uploadLocksTable.columnTime} < ?',
@@ -241,7 +235,7 @@ class UploadLocksDB {
String fileHash,
int collectionID,
) async {
final db = await instance.database;
final db = await database;
final rows = await db.query(
_trackUploadTable.table,
@@ -268,7 +262,7 @@ class UploadLocksDB {
String fileHash,
int collectionID,
) async {
final db = await instance.database;
final db = await database;
await db.update(
_trackUploadTable.table,
{
@@ -291,7 +285,7 @@ class UploadLocksDB {
String fileHash,
int collectionID,
) async {
final db = await instance.database;
final db = await database;
final rows = await db.query(
_trackUploadTable.table,
where: '${_trackUploadTable.columnLocalID} = ?'
@@ -349,7 +343,7 @@ class UploadLocksDB {
int uploadedFileID,
String errorMessage,
) async {
final db = await UploadLocksDB.instance.database;
final db = await database;
await db.insert(
_streamUploadErrorTable.table,
@@ -367,7 +361,7 @@ class UploadLocksDB {
int uploadedFileID,
String errorMessage,
) async {
final db = await instance.database;
final db = await database;
await db.update(
_streamUploadErrorTable.table,
{
@@ -381,7 +375,7 @@ class UploadLocksDB {
}
Future<int> deleteStreamUploadErrorEntry(int uploadedFileID) async {
final db = await instance.database;
final db = await database;
return await db.delete(
_streamUploadErrorTable.table,
where: '${_streamUploadErrorTable.columnUploadedFileID} = ?',
@@ -390,7 +384,7 @@ class UploadLocksDB {
}
Future<Map<int, String>> getStreamUploadError() {
return instance.database.then((db) async {
return database.then((db) async {
final rows = await db.query(
_streamUploadErrorTable.table,
columns: [
@@ -419,7 +413,7 @@ class UploadLocksDB {
String keyNonce, {
required int partSize,
}) async {
final db = await UploadLocksDB.instance.database;
final db = await database;
final objectKey = urls.objectKey;
await db.insert(
@@ -462,7 +456,7 @@ class UploadLocksDB {
int partNumber,
String etag,
) async {
final db = await instance.database;
final db = await database;
await db.update(
_partsTable.table,
{
@@ -479,7 +473,7 @@ class UploadLocksDB {
String objectKey,
MultipartStatus status,
) async {
final db = await instance.database;
final db = await database;
await db.update(
_trackUploadTable.table,
{
@@ -493,7 +487,7 @@ class UploadLocksDB {
Future<int> deleteMultipartTrack(
String localId,
) async {
final db = await instance.database;
final db = await database;
return await db.delete(
_trackUploadTable.table,
where: '${_trackUploadTable.columnLocalID} = ?',
@@ -503,7 +497,7 @@ class UploadLocksDB {
// getFileNameToLastAttemptedAtMap returns a map of encrypted file name to last attempted at time
Future<Map<String, int>> getFileNameToLastAttemptedAtMap() {
return instance.database.then((db) async {
return database.then((db) async {
final rows = await db.query(
_trackUploadTable.table,
columns: [
@@ -525,7 +519,7 @@ class UploadLocksDB {
String fileHash,
int collectionID,
) {
return instance.database.then((db) async {
return database.then((db) async {
final rows = await db.query(
_trackUploadTable.table,
where: '${_trackUploadTable.columnLocalID} = ?'
@@ -546,7 +540,7 @@ class UploadLocksDB {
int uploadedFileID,
String queueType, // 'create' or 'recreate'
) async {
final db = await instance.database;
final db = await database;
await db.insert(
_streamQueueTable.table,
{
@@ -558,7 +552,7 @@ class UploadLocksDB {
}
Future<void> removeFromStreamQueue(int uploadedFileID) async {
final db = await instance.database;
final db = await database;
await db.delete(
_streamQueueTable.table,
where: '${_streamQueueTable.columnUploadedFileID} = ?',
@@ -567,7 +561,7 @@ class UploadLocksDB {
}
Future<Map<int, String>> getStreamQueue() async {
final db = await instance.database;
final db = await database;
final rows = await db.query(
_streamQueueTable.table,
columns: [
@@ -584,7 +578,7 @@ class UploadLocksDB {
}
Future<bool> isInStreamQueue(int uploadedFileID) async {
final db = await instance.database;
final db = await database;
final rows = await db.query(
_streamQueueTable.table,
where: '${_streamQueueTable.columnUploadedFileID} = ?',

View File

@@ -1776,11 +1776,6 @@
"same": "نفس",
"different": "مختلف",
"sameperson": "نفس الشخص؟",
"cLTitle1": "محرر الصور المتقدم",
"cLDesc1": "نحن بصدد إطلاق محرر صور جديد ومتقدم يضيف المزيد من إطارات الاقتصاص، والإعدادات المسبقة للفلاتر من أجل تعديلات سريعة، وخيارات الضبط الدقيق التي تشمل التشبع، والتباين، والسطوع، ودرجة الحرارة، وغير ذلك الكثير. يتضمن المحرر الجديد أيضا القدرة على الرسم على صورك وإضافة الرموز التعبيرية كملصقات.",
"cLTitle2": "ألبومات ذكية",
"cLTitle3": "معرض محسن",
"cLTitle4": "تمرير أسرع",
"thisWeek": "هذا الأسبوع",
"lastWeek": "الأسبوع الماضي",
"thisMonth": "هذا الشهر",
@@ -1821,4 +1816,4 @@
}
}
}
}
}

View File

@@ -314,7 +314,7 @@
}
}
},
"faq": "Často kladené dotazy",
"faq": "Často kladené dotazy (FAQ)",
"help": "Nápověda",
"oopsSomethingWentWrong": "Jejda, něco se pokazilo",
"peopleUsingYourCode": "Lidé, kteří používají váš kód",
@@ -498,7 +498,7 @@
"authToChangeYourEmail": "Pro změnu e-mailové adresy se prosím ověřte",
"changePassword": "Změnit heslo",
"authToChangeYourPassword": "Pro změnu hesla se prosím ověřte",
"emailVerificationToggle": "Ověření emailem",
"emailVerificationToggle": "Ověření pomocí e-mailu",
"authToChangeEmailVerificationSetting": "Pro změnu ověření pomocí emailu se musíte ověřit",
"exportYourData": "Exportujte svá data",
"logout": "Odhlásit se",
@@ -594,7 +594,7 @@
"theme": "Motiv",
"lightTheme": "Světlý",
"darkTheme": "Tmavý",
"systemTheme": "Podle systému",
"systemTheme": "Systém",
"freeTrial": "Bezplatná zkušební verze",
"selectYourPlan": "Vyberte svůj tarif",
"enteSubscriptionPitch": "Ente uchovává vaše vzpomínky, takže jsou vám vždy k dispozici, i když ztratíte své zařízení.",
@@ -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",
@@ -1827,5 +1819,98 @@
"type": "int"
}
}
}
}
},
"videosProcessed": "Zpracovaná videa",
"totalVideos": "Celkový počet videí",
"skippedVideos": "Přeskočená videa",
"videoStreamingNote": "Na tomto zařízení se zpracovávají pouze videa z posledních 60 dnů, která jsou kratší než 1 minuta. U starších/delších videí povolte streamování v desktopové aplikaci.",
"createStream": "Vytvořit stream",
"recreateStream": "Obnovit stream",
"addedToStreamCreationQueue": "Přidáno do fronty pro vytvoření streamu",
"addedToStreamRecreationQueue": "Přidáno do fronty pro obnovení streamu",
"videoPreviewAlreadyExists": "Náhled videa již existuje",
"videoAlreadyInQueue": "Video soubor již je ve frontě",
"addedToQueue": "Přidáno do fronty",
"creatingStream": "Vytváření streamu",
"similarImages": "Podobné obrázky",
"findSimilarImages": "Najít podobné obrázky",
"noSimilarImagesFound": "Nebyly nalezeny žádné podobné obrázky",
"yourPhotosLookUnique": "Vaše fotografie vypadají jedinečně",
"similarGroupsFound": "{count, plural, =1{{count} skupina nalezena} few{{count} skupiny nalezeny} other{{count} skupin nalezeno}}",
"@similarGroupsFound": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"reviewAndRemoveSimilarImages": "Zkontrolujte a odstraňte podobné obrázky",
"deletePhotosWithSize": "Smazat {count} fotek ({size})",
"@deletePhotosWithSize": {
"placeholders": {
"count": {
"type": "String"
},
"size": {
"type": "String"
}
}
},
"selectionOptions": "Možnosti výběru",
"selectExactWithCount": "Úplně stejné ({count})",
"@selectExactWithCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"selectExact": "Vybrat shodné",
"selectSimilarWithCount": "Hodně podobné ({count})",
"@selectSimilarWithCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"selectSimilar": "Vyberte podobné",
"selectAllWithCount": "Všechny podobnosti ({count})",
"@selectAllWithCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"selectSimilarImagesTitle": "Vyberte podobné obrázky",
"chooseSimilarImagesToSelect": "Vyberte obrázky na základě jejich vizuální podobnosti",
"clearSelection": "Vymazat výběr",
"similarImagesCount": "{count} podobných obrázků",
"@similarImagesCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"deleteWithCount": "Smazat ({count})",
"@deleteWithCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"deleteFiles": "Smazat soubory",
"areYouSureDeleteFiles": "Opravdu chcete tyto soubory smazat?",
"greatJob": "Dobrá práce!",
"size": "Velikost",
"similarity": "Podobnost",
"processingLocally": "Místní zpracování",
"useMLToFindSimilarImages": "Zkontrolujte a odstraňte obrázky, které se navzájem podobají.",
"all": "Vše",
"similar": "Podobné",
"identical": "Identické",
"nothingHereTryAnotherFilter": "Tady nic není, zkuste jiný filtr! 👀"
}

View File

@@ -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",
@@ -1827,5 +1819,117 @@
"type": "int"
}
}
}
},
"videosProcessed": "Videos verarbeitet",
"totalVideos": "Videos insgesamt",
"skippedVideos": "Übersprungene Videos",
"videoStreamingDescriptionLine1": "Videos sofort auf jedem Gerät abspielen.",
"videoStreamingDescriptionLine2": "Aktivieren, um Video-Streams auf diesem Gerät zu verarbeiten.",
"videoStreamingNote": "Nur Videos der letzten 60 Tage und unter einer Minute werden auf diesem Gerät verarbeitet. Für ältere/längere Videos aktiviere das Streaming in der Desktop-App.",
"createStream": "Stream erzeugen",
"recreateStream": "Stream neu erzeugen",
"addedToStreamCreationQueue": "Zur Warteschlange für Streamerstellung hinzugefügt",
"addedToStreamRecreationQueue": "Zur Warteschlange für Neuerstellung der Streams hinzugefügt",
"videoPreviewAlreadyExists": "Videovorschau existiert bereits",
"videoAlreadyInQueue": "Videodatei existiert bereits in der Warteschlange",
"addedToQueue": "Zur Warteschlange hinzugefügt",
"creatingStream": "Stream wird erzeugt",
"similarImages": "Ähnliche Bilder",
"findSimilarImages": "Ähnliche Bilder finden",
"noSimilarImagesFound": "Keine ähnlichen Bilder gefunden",
"yourPhotosLookUnique": "Deine Fotos sehen einzigartig aus",
"similarGroupsFound": "{count, plural, =1{Eine Gruppe gefunden} other{{count} Gruppen gefunden}}",
"@similarGroupsFound": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"reviewAndRemoveSimilarImages": "Überprüfe und lösche ähnliche Bilder",
"deletePhotosWithSize": "Lösche {count} Fotos ({size})",
"@deletePhotosWithSize": {
"placeholders": {
"count": {
"type": "String"
},
"size": {
"type": "String"
}
}
},
"selectionOptions": "Auswahloptionen",
"selectExactWithCount": "Exakt gleich ({count})",
"@selectExactWithCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"selectExact": "Exakte auswählen",
"selectSimilarWithCount": "Nahezu gleich ({count})",
"@selectSimilarWithCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"selectSimilar": "Ähnliche auswählen",
"selectAllWithCount": "Alle Ähnlichkeiten ({count})",
"@selectAllWithCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"selectSimilarImagesTitle": "Ähnliche Bilder auswählen",
"chooseSimilarImagesToSelect": "Wähle Bilder anhand ihrer visuellen Ähnlichkeit",
"clearSelection": "Auswahl aufheben",
"similarImagesCount": "{count} ähnliche Bilder",
"@similarImagesCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"deleteWithCount": "Löschen ({count})",
"@deleteWithCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"deleteFiles": "Dateien löschen",
"areYouSureDeleteFiles": "Bist du sicher, dass du diese Dateien löschen willst?",
"greatJob": "Gut gemacht!",
"cleanedUpSimilarImages": "Du hast {size} an Speicherplatz freigegeben",
"@cleanedUpSimilarImages": {
"placeholders": {
"size": {
"type": "String"
}
}
},
"size": "Größe",
"similarity": "Ähnlichkeit",
"processingLocally": "Lokale Verarbeitung",
"useMLToFindSimilarImages": "Überprüfe und entferne Bilder, die sich ähnlich sehen.",
"all": "Alle",
"similar": "Ähnlich",
"identical": "Identisch",
"nothingHereTryAnotherFilter": "Nichts zu sehen, probier einen anderen Filter! 👀",
"related": "Verwandt",
"hoorayyyy": "Hurraaaa!",
"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"
}

View File

@@ -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",
@@ -1831,7 +1823,8 @@
"videosProcessed": "Videos processed",
"totalVideos": "Total videos",
"skippedVideos": "Skipped videos",
"videoStreamingDescription": "Play videos instantly on any device. Enable to process video streams on this device.",
"videoStreamingDescriptionLine1": "Play videos instantly on any device.",
"videoStreamingDescriptionLine2": "Enable to process video streams on this device.",
"videoStreamingNote": "Only videos from last 60 days and under 1 minute are processed on this device. For older/longer videos, enable streaming in the desktop app.",
"createStream": "Create stream",
"recreateStream": "Recreate stream",
@@ -1842,14 +1835,6 @@
"addedToQueue": "Added to queue",
"creatingStream": "Creating stream",
"similarImages": "Similar images",
"deletingProgress": "Deleting... {progress}",
"@deletingProgress": {
"placeholders": {
"progress": {
"type": "String"
}
}
},
"findSimilarImages": "Find similar images",
"noSimilarImagesFound": "No similar images found",
"yourPhotosLookUnique": "Your photos look unique",
@@ -1866,7 +1851,7 @@
"@deletePhotosWithSize": {
"placeholders": {
"count": {
"type": "int"
"type": "String"
},
"size": {
"type": "String"
@@ -1922,12 +1907,9 @@
"deleteFiles": "Delete files",
"areYouSureDeleteFiles": "Are you sure you want to delete these files?",
"greatJob": "Great job!",
"cleanedUpSimilarImages": "You cleaned up {count, plural, =1{{count} similar image} other{{count} similar images}} and freed up {size}",
"cleanedUpSimilarImages": "You freed up {size} of space",
"@cleanedUpSimilarImages": {
"placeholders": {
"count": {
"type": "int"
},
"size": {
"type": "String"
}
@@ -1935,11 +1917,25 @@
},
"size": "Size",
"similarity": "Similarity",
"analyzingPhotosLocally": "Analyzing your photos locally",
"lookingForVisualSimilarities": "Looking for visual similarities",
"comparingImageDetails": "Comparing image details",
"findingSimilarImages": "Finding similar images",
"almostDone": "Almost done",
"analyzingPhotosLocally": "Analyzing your photos locally...",
"lookingForVisualSimilarities": "Looking for visual similarities...",
"comparingImageDetails": "Comparing image details...",
"findingSimilarImages": "Finding similar images...",
"almostDone": "Almost done...",
"processingLocally": "Processing locally",
"useMLToFindSimilarImages": "Use ML to find images that look similar to each other."
"useMLToFindSimilarImages": "Review and remove images that look similar to each other.",
"all": "All",
"similar": "Similar",
"identical": "Identical",
"nothingHereTryAnotherFilter": "Nothing here, try another filter! 👀",
"related": "Related",
"hoorayyyy": "Hoorayyyy!",
"nothingToTidyUpHere": "Nothing to tidy up here",
"deletingDash": "Deleting - ",
"cLTitle1": "Similar images",
"cLDesc1": "We are introducing a new ML-based system to detect similar images, using which you can cleanup your library. Available in Settings -> Backup -> Free up space",
"cLTitle2": "Video streaming enhancements",
"cLDesc2": "You can now manually trigger stream generation for videos directly from the app. We have also added a new video streaming settings screen which will show you what percentage of your videos have been processed for streaming",
"cLTitle3": "Performance improvements",
"cLDesc3": "Multiple under the hood improvements, including better cache usage and a smoother scroll experience"
}

View File

@@ -159,7 +159,7 @@
"addCollaborator": "Agregar colaborador",
"addANewEmail": "Agregar nuevo correo electrónico",
"orPickAnExistingOne": "O elige uno existente",
"collaboratorsCanAddPhotosAndVideosToTheSharedAlbum": "Colaboradores pueden añadir fotos y videos al álbum compartido.",
"collaboratorsCanAddPhotosAndVideosToTheSharedAlbum": "Colaboradores pueden añadir fotos y vídeos al álbum compartido.",
"enterEmail": "Ingresar correo electrónico ",
"albumOwner": "Propietario",
"@albumOwner": {
@@ -270,7 +270,7 @@
"shareTextConfirmOthersVerificationID": "Hola, ¿puedes confirmar que esta es tu ID de verificación ente.io: {verificationID}?",
"somethingWentWrong": "Algo salió mal",
"sendInvite": "Enviar invitación",
"shareTextRecommendUsingEnte": "Descarga Ente para que podamos compartir fácilmente fotos y videos en calidad original.\n\nhttps://ente.io",
"shareTextRecommendUsingEnte": "Descarga Ente para que podamos compartir fácilmente fotos y vídeos en calidad original.\n\nhttps://ente.io",
"done": "Hecho",
"applyCodeTitle": "Usar código",
"enterCodeDescription": "Introduce el código proporcionado por tu amigo para reclamar almacenamiento gratuito para ambos",
@@ -857,7 +857,7 @@
"deviceFilesAutoUploading": "Los archivos añadidos a este álbum de dispositivo se subirán automáticamente a Ente.",
"turnOnBackupForAutoUpload": "Activar la copia de seguridad para subir automáticamente archivos añadidos a la carpeta de este dispositivo a Ente.",
"noHiddenPhotosOrVideos": "No hay fotos ni vídeos ocultos",
"toHideAPhotoOrVideo": "Para ocultar una foto o video",
"toHideAPhotoOrVideo": "Para ocultar una foto o vídeo",
"openTheItem": "• Abrir el elemento",
"clickOnTheOverflowMenu": "• Haga clic en el menú desbordante",
"click": "• Clic",
@@ -866,7 +866,7 @@
"archiveAlbum": "Archivar álbum",
"calculating": "Calculando...",
"pleaseWaitDeletingAlbum": "Por favor espera. Borrando el álbum",
"searchByExamples": "• Nombres de álbumes (por ejemplo, \"Cámara\")\n• Tipos de archivos (por ejemplo, \"Videos\", \".gif\")\n• Años y meses (por ejemplo, \"2022\", \"Enero\")\n• Vacaciones (por ejemplo, \"Navidad\")\n• Descripciones fotográficas (por ejemplo, \"#diversión\")",
"searchByExamples": "• Nombres de álbumes (por ejemplo, \"Cámara\")\n• Tipos de archivos (por ejemplo, \"Vídeos\", \".gif\")\n• Años y meses (por ejemplo, \"2022\", \"Enero\")\n• Vacaciones (por ejemplo, \"Navidad\")\n• Descripciones fotográficas (por ejemplo, \"#diversión\")",
"youCanTrySearchingForADifferentQuery": "Puedes intentar buscar una consulta diferente.",
"noResultsFound": "No se han encontrado resultados",
"addedBy": "Añadido por {emailOrName}",
@@ -884,8 +884,8 @@
"filesSavedToGallery": "Archivo guardado en la galería",
"fileFailedToSaveToGallery": "No se pudo guardar el archivo en la galería",
"download": "Descargar",
"pressAndHoldToPlayVideo": "Presiona y mantén presionado para reproducir el video",
"pressAndHoldToPlayVideoDetailed": "Mantén pulsada la imagen para reproducir el video",
"pressAndHoldToPlayVideo": "Presiona y mantén presionado para reproducir el vídeo",
"pressAndHoldToPlayVideoDetailed": "Mantén pulsada la imagen para reproducir el vídeo",
"downloadFailed": "Descarga fallida",
"deduplicateFiles": "Deduplicar archivos",
"deselectAll": "Deseleccionar todo",
@@ -895,7 +895,7 @@
"count": "Cuenta",
"totalSize": "Tamaño total",
"longpressOnAnItemToViewInFullscreen": "Manten presionado un elemento para ver en pantalla completa",
"decryptingVideo": "Descifrando video...",
"decryptingVideo": "Descifrando vídeo...",
"authToViewYourMemories": "Por favor, autentícate para ver tus recuerdos",
"unlock": "Desbloquear",
"freeUpSpace": "Liberar espacio",
@@ -1014,7 +1014,7 @@
"cachedData": "Datos almacenados en caché",
"clearCaches": "Limpiar cachés",
"remoteImages": "Imágenes remotas",
"remoteVideos": "Videos remotos",
"remoteVideos": "Vídeos remotos",
"remoteThumbnails": "Miniaturas remotas",
"pendingSync": "Sincronización pendiente",
"localGallery": "Galería local",
@@ -1169,7 +1169,7 @@
"description": "Label for the map view"
},
"maps": "Mapas",
"enableMaps": "Activar Mapas",
"enableMaps": "Habilitar mapas",
"enableMapsDesc": "Esto mostrará tus fotos en el mapa mundial.\n\nEste mapa está gestionado por Open Street Map, y la ubicación exacta de tus fotos nunca se comparte.\n\nPuedes deshabilitar esta función en cualquier momento en Ajustes.",
"quickLinks": "Acceso rápido",
"selectItemsToAdd": "Selecciona elementos para agregar",
@@ -1346,7 +1346,7 @@
"noSystemLockFound": "Bloqueo de sistema no encontrado",
"tapToUnlock": "Toca para desbloquear",
"tooManyIncorrectAttempts": "Demasiados intentos incorrectos",
"videoInfo": "Información de video",
"videoInfo": "Información de vídeo",
"autoLock": "Bloqueo automático",
"immediately": "Inmediatamente",
"autoLockFeatureDescription": "Tiempo después de que la aplicación esté en segundo plano",
@@ -1433,7 +1433,7 @@
"description": "In session page, warn user (in toast) that active sessions could not be fetched."
},
"failedToRefreshStripeSubscription": "Error al actualizar la suscripción",
"failedToPlayVideo": "Error al reproducir el video",
"failedToPlayVideo": "Error al reproducir el vídeo",
"uploadIsIgnoredDueToIgnorereason": "La subida se ignoró debido a {ignoreReason}",
"@uploadIsIgnoredDueToIgnorereason": {
"placeholders": {
@@ -1588,7 +1588,7 @@
},
"legacyInvite": "{email} te ha invitado a ser un contacto de confianza",
"authToManageLegacy": "Por favor, autentícate para administrar tus contactos de confianza",
"useDifferentPlayerInfo": "¿Tienes problemas para reproducir este video? Mantén pulsado aquí para probar un reproductor diferente.",
"useDifferentPlayerInfo": "¿Tienes problemas para reproducir este vídeo? Mantén pulsado aquí para probar un reproductor diferente.",
"hideSharedItemsFromHomeGallery": "Ocultar elementos compartidos de la galería de inicio",
"gallery": "Galería",
"joinAlbum": "Unir álbum",
@@ -1662,7 +1662,7 @@
"@linkPersonCaption": {
"description": "Caption for the 'Link person' title. It should be a continuation of the 'Link person' title. Just like how 'Link person' + 'for better sharing experience' forms a proper sentence in English, the combination of these two strings should also be a proper sentence in other languages."
},
"videoStreaming": "Vídeos en streaming",
"videoStreaming": "Vídeos en transmisión",
"processingVideos": "Procesando vídeos",
"streamDetails": "Detalles de la transmisión",
"processing": "Procesando",
@@ -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",
@@ -1827,5 +1819,117 @@
"type": "int"
}
}
}
},
"videosProcessed": "Vídeos procesados",
"totalVideos": "Vídeos totales",
"skippedVideos": "Vídeos omitidos",
"videoStreamingDescriptionLine1": "Reproduce vídeos al instante en cualquier dispositivo.",
"videoStreamingDescriptionLine2": "Habilitar para procesar transmisiones de vídeo en este dispositivo.",
"videoStreamingNote": "Solo los vídeos de los últimos 60 días y menos de 1 minuto se procesan en este dispositivo. Para vídeos más viejos/más largos, habilita la transmisión en la aplicación de escritorio.",
"createStream": "Crear transmisión",
"recreateStream": "Recrear transmisión",
"addedToStreamCreationQueue": "Añadido a la cola de creación de transmisiones",
"addedToStreamRecreationQueue": "Añadido a la cola de grabación de transmisiones",
"videoPreviewAlreadyExists": "La vista previa de vídeo ya existe",
"videoAlreadyInQueue": "El archivo de vídeo ya está en la cola",
"addedToQueue": "Añadido a la cola",
"creatingStream": "Creando transmisión",
"similarImages": "Imágenes similares",
"findSimilarImages": "Buscar imágenes similares",
"noSimilarImagesFound": "No se encontraron imágenes similares",
"yourPhotosLookUnique": "Tus fotos se ven únicas",
"similarGroupsFound": "{count, plural, one {}=1{{count} grupo encontrado} other{{count} grupos encontrados}}",
"@similarGroupsFound": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"reviewAndRemoveSimilarImages": "Revisar y eliminar imágenes similares",
"deletePhotosWithSize": "Eliminar {count} fotos ({size})",
"@deletePhotosWithSize": {
"placeholders": {
"count": {
"type": "String"
},
"size": {
"type": "String"
}
}
},
"selectionOptions": "Opciones de selección",
"selectExactWithCount": "Exactamente similar ({count})",
"@selectExactWithCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"selectExact": "Seleccionar exactos",
"selectSimilarWithCount": "Mayormente, similar ({count})",
"@selectSimilarWithCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"selectSimilar": "Seleccionar similares",
"selectAllWithCount": "Todas las similitudes ({count})",
"@selectAllWithCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"selectSimilarImagesTitle": "Seleccionar imágenes similares",
"chooseSimilarImagesToSelect": "Seleccionar imágenes basándose en su similitud visual",
"clearSelection": "Borrar selección",
"similarImagesCount": "{count} imágenes similares",
"@similarImagesCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"deleteWithCount": "Eliminar ({count})",
"@deleteWithCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"deleteFiles": "Eliminar archivos",
"areYouSureDeleteFiles": "¿Estás seguro que quieres eliminar estos archivos?",
"greatJob": "¡Bien hecho!",
"cleanedUpSimilarImages": "Has liberado {size} de espacio",
"@cleanedUpSimilarImages": {
"placeholders": {
"size": {
"type": "String"
}
}
},
"size": "Tamaño",
"similarity": "Similitud",
"processingLocally": "Procesando localmente",
"useMLToFindSimilarImages": "Revisar y eliminar imágenes que se parecen entre sí.",
"all": "Todas",
"similar": "Similares",
"identical": "Idénticas",
"nothingHereTryAnotherFilter": "Nada aquí, ¡prueba con otro filtro! 👀",
"related": "Relacionado",
"hoorayyyy": "¡Hurraaaa!",
"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"
}

View File

@@ -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",
@@ -1827,5 +1819,109 @@
"type": "int"
}
}
}
},
"videosProcessed": "Vidéos traitées",
"totalVideos": "Total de vidéos",
"skippedVideos": "Vidéos ignorées",
"videoStreamingDescriptionLine1": "Lire instantanément des vidéos sur n'importe quel appareil.",
"videoStreamingDescriptionLine2": "Activer pour traiter les flux vidéo sur cet appareil.",
"videoStreamingNote": "Seules les vidéos des 60 derniers jours et de moins d'une minute sont traitées sur cet appareil. Pour les vidéos plus anciennes/plus longues, activez le streaming dans l'application pour ordinateur.",
"createStream": "Créer le flux",
"recreateStream": "Recréer le flux",
"addedToStreamCreationQueue": "Ajouté à la file d'attente de création de flux",
"addedToStreamRecreationQueue": "Ajouté à la file d'attente de re-création de flux",
"videoPreviewAlreadyExists": "L'aperçu vidéo existe déjà",
"videoAlreadyInQueue": "Fichier vidéo déjà présent dans la file d'attente",
"addedToQueue": "Ajouté à la file d'attente",
"creatingStream": "Création du flux",
"similarImages": "Images similaires",
"findSimilarImages": "Rechercher des images similaires",
"noSimilarImagesFound": "Aucune image similaire trouvée",
"yourPhotosLookUnique": "Vos photos semblent uniques",
"reviewAndRemoveSimilarImages": "Examiner et supprimer les images similaires",
"deletePhotosWithSize": "Supprimer {count} photos ({size})",
"@deletePhotosWithSize": {
"placeholders": {
"count": {
"type": "String"
},
"size": {
"type": "String"
}
}
},
"selectionOptions": "Options de sélection",
"selectExactWithCount": "Exactement similaire ({count})",
"@selectExactWithCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"selectExact": "Sélectionner exactement",
"selectSimilarWithCount": "Plutôt similaire ({count})",
"@selectSimilarWithCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"selectSimilar": "Sélectionner à l'identique",
"selectAllWithCount": "Toutes les similarités ({count})",
"@selectAllWithCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"selectSimilarImagesTitle": "Sélectionner les images similaires",
"chooseSimilarImagesToSelect": "Sélectionnez des images en fonction de leur similitude visuelle",
"clearSelection": "Effacer la sélection",
"similarImagesCount": "{count} images similaires",
"@similarImagesCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"deleteWithCount": "Supprimer ({count})",
"@deleteWithCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"deleteFiles": "Supprimer les fichiers",
"areYouSureDeleteFiles": "Êtes-vous sûr de vouloir supprimer ces fichiers ?",
"greatJob": "Excellent !",
"cleanedUpSimilarImages": "Vous avez libéré {size} d'espace",
"@cleanedUpSimilarImages": {
"placeholders": {
"size": {
"type": "String"
}
}
},
"size": "Taille",
"similarity": "Similitude",
"processingLocally": "Traitement local",
"useMLToFindSimilarImages": "Examinez et supprimez les images qui se ressemblent entre elles.",
"all": "Toutes",
"similar": "Similaires",
"identical": "Identiques",
"nothingHereTryAnotherFilter": "Rien ici, essayez un autre filtre ! 👀",
"related": "Liés",
"hoorayyyy": "Houraaa !",
"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"
}

View File

@@ -189,6 +189,7 @@
"allowAddPhotosDescription": "Izinkan orang yang memiliki link untuk menambahkan foto ke album berbagi ini.",
"passwordLock": "Kunci dengan sandi",
"canNotOpenTitle": "Tidak dapat membuka album ini",
"canNotOpenBody": "Maaf, album ini tidak dapat dibuka di aplikasi.",
"disableDownloadWarningTitle": "Perlu diketahui",
"disableDownloadWarningBody": "Orang yang melihat masih bisa mengambil tangkapan layar atau menyalin foto kamu menggunakan alat eksternal",
"allowDownloads": "Izinkan pengunduhan",
@@ -355,6 +356,7 @@
"failedToLoadAlbums": "Gagal memuat album",
"hidden": "Tersembunyi",
"authToViewYourHiddenFiles": "Harap autentikasi untuk melihat file tersembunyi kamu",
"authToViewTrashedFiles": "Silakan autentikasi untuk melihat file anda yang ada di tong sampah",
"trash": "Sampah",
"uncategorized": "Tak Berkategori",
"videoSmallCase": "video",
@@ -370,6 +372,21 @@
"deleteFromBoth": "Hapus dari keduanya",
"newAlbum": "Album baru",
"albums": "Album",
"memoryCount": "{count, plural, =0{tidak ada memori} one{{formattedCount} memori} other{{formattedCount} memori}}",
"@memoryCount": {
"description": "The text to display the number of memories",
"type": "text",
"placeholders": {
"count": {
"example": "1",
"type": "int"
},
"formattedCount": {
"type": "String",
"example": "11.513, 11,511"
}
}
},
"selectedPhotos": "{count} terpilih",
"@selectedPhotos": {
"description": "Display the number of selected photos",
@@ -419,6 +436,7 @@
"discover_receipts": "Tanda Terima",
"discover_notes": "Catatan",
"discover_memes": "Meme",
"discover_visiting_cards": "Kartu Nama",
"discover_babies": "Bayi",
"discover_pets": "Hewan",
"discover_selfies": "Swafoto",
@@ -427,6 +445,7 @@
"discover_celebrations": "Perayaan",
"discover_sunset": "Senja",
"discover_hills": "Bukit",
"discover_greenery": "Hijau-hijauan",
"mlIndexingDescription": "Perlu diperhatikan bahwa pemelajaran mesin dapat meningkatkan penggunaan data dan baterai perangkat hingga seluruh item selesai terindeks. Gunakan aplikasi desktop untuk pengindeksan lebih cepat, seluruh hasil akan tersinkronkan secara otomatis.",
"loadingModel": "Mengunduh model...",
"waitingForWifi": "Menunggu WiFi...",
@@ -442,6 +461,21 @@
"updatingFolderSelection": "Memperbaharui pilihan folder...",
"itemCount": "{count, plural, other{{count} item}}",
"deleteItemCount": "{count, plural, =1 {Hapus {count} item} other {Hapus {count} item}}",
"duplicateItemsGroup": "{count} berkas, masing-masing {formattedSize}",
"@duplicateItemsGroup": {
"description": "Display the number of duplicate files and their size",
"type": "text",
"placeholders": {
"count": {
"example": "12",
"type": "int"
},
"formattedSize": {
"example": "2.3 MB",
"type": "String"
}
}
},
"showMemories": "Lihat kenangan",
"yearsAgo": "{count, plural, other{{count} tahun lalu}}",
"backupSettings": "Pengaturan pencadangan",
@@ -492,6 +526,7 @@
"viewLargeFiles": "File berukuran besar",
"viewLargeFilesDesc": "Tampilkan file yang paling besar mengonsumsi ruang penyimpanan.",
"noDuplicates": "✨ Tak ada file duplikat",
"youveNoDuplicateFilesThatCanBeCleared": "Anda tidak memiliki file duplikat yang dapat dihapus",
"success": "Berhasil",
"rateUs": "Beri kami nilai",
"remindToEmptyDeviceTrash": "Kosongkan juga “Baru Dihapus” dari “Pengaturan” -> “Penyimpanan” untuk memperoleh ruang yang baru saja dibersihkan",
@@ -658,6 +693,7 @@
"rateTheApp": "Nilai app ini",
"startBackup": "Mulai pencadangan",
"noPhotosAreBeingBackedUpRightNow": "Tidak ada foto yang sedang dicadangkan sekarang",
"preserveMore": "Amankan lebih banyak",
"grantFullAccessPrompt": "Harap berikan akses ke semua foto di app Pengaturan",
"allowPermTitle": "Izinkan akses ke foto",
"allowPermBody": "Ijinkan akses ke foto Anda dari Pengaturan agar Ente dapat menampilkan dan mencadangkan pustaka Anda.",
@@ -714,6 +750,7 @@
"lastUpdated": "Terakhir diperbarui",
"deleteEmptyAlbums": "Hapus album kosong",
"deleteEmptyAlbumsWithQuestionMark": "Hapus album yang kosong?",
"deleteAlbumsDialogBody": "Ini akan menghapus semua album kosong. Ini berguna ketika anda ingin mengurangi kekacauan di daftar album anda.",
"deleteProgress": "Menghapus {currentlyDeleting} / {totalCount}",
"genericProgress": "Memproses {currentlyProcessing} / {totalCount}",
"@genericProgress": {
@@ -731,6 +768,7 @@
}
},
"permanentlyDelete": "Hapus secara permanen",
"canOnlyCreateLinkForFilesOwnedByYou": "Hanya dapat membuat tautan untuk file yang dimiliki oleh anda",
"publicLinkCreated": "Link publik dibuat",
"youCanManageYourLinksInTheShareTab": "Kamu bisa atur link yang telah kamu buat di tab berbagi.",
"linkCopiedToClipboard": "Link tersalin ke papan klip",
@@ -740,15 +778,30 @@
"type": "text"
},
"moveToAlbum": "Pindahkan ke album",
"unhide": "Tampilkan",
"unarchive": "Keluarkan dari arsip",
"favorite": "Favorit",
"removeFromFavorite": "Hapus dari favorit",
"shareLink": "Bagikan link",
"createCollage": "Buat kolase",
"saveCollage": "Simpan kolase",
"collageSaved": "Kolase tersimpan ke galeri",
"collageLayout": "Tata letak",
"addToEnte": "Tambah ke Ente",
"addToAlbum": "Tambah ke album",
"delete": "Hapus",
"hide": "Sembunyikan",
"share": "Bagikan",
"unhideToAlbum": "Tampikan ke album",
"restoreToAlbum": "Pulihkan ke album",
"moveItem": "{count, plural, =1 {Pindahkan item} other {Pindahkan item}}",
"@moveItem": {
"description": "Page title while moving one or more items to an album"
},
"addItem": "{count, plural, =1 {Tambahkan item} other {Tambahkan item}}",
"@addItem": {
"description": "Page title while adding one or more items to album"
},
"createOrSelectAlbum": "Buat atau pilih album",
"selectAlbum": "Pilih album",
"searchByAlbumNameHint": "Nama album",
@@ -756,18 +809,33 @@
"enterAlbumName": "Masukkan nama album",
"restoringFiles": "Memulihkan file...",
"movingFilesToAlbum": "Memindahkan file ke album...",
"unhidingFilesToAlbum": "Tampilkan berkas ke album",
"canNotUploadToAlbumsOwnedByOthers": "Tidak dapat mengunggah album yang dimiliki oleh orang lain",
"uploadingFilesToAlbum": "Mengunggah file ke album...",
"addedSuccessfullyTo": "Berhasil ditambahkan ke {albumName}",
"movedSuccessfullyTo": "Berhasil dipindahkan ke {albumName}",
"thisAlbumAlreadyHDACollaborativeLink": "Link kolaborasi untuk album ini sudah terbuat",
"collaborativeLinkCreatedFor": "Link kolaborasi terbuat untuk {albumName}",
"askYourLovedOnesToShare": "Minta orang terkasih anda untuk berbagi",
"invite": "Undang",
"shareYourFirstAlbum": "Bagikan album pertamamu",
"sharedWith": "Dibagikan dengan {emailIDs}",
"sharedWithMe": "Dibagikan dengan saya",
"sharedByMe": "Dibagikan oleh saya",
"doubleYourStorage": "Gandakan kuota kamu",
"referFriendsAnd2xYourPlan": "Ajak teman dan gandakan paket anda",
"shareAlbumHint": "Buka album lalu ketuk tombol bagikan di sudut kanan atas untuk berbagi.",
"itemsShowTheNumberOfDaysRemainingBeforePermanentDeletion": "Item menampilkan jumlah hari yang tersisa sebelum dihapus permanen",
"trashDaysLeft": "{count, plural, =0 {Segera} =1 {1 hari} other {{count} hari}}",
"@trashDaysLeft": {
"description": "Text to indicate number of days remaining before permanent deletion",
"placeholders": {
"count": {
"example": "1|2|3",
"type": "int"
}
}
},
"deleteAll": "Hapus Semua",
"renameAlbum": "Ubah nama album",
"convertToAlbum": "Ubah menjadi album",
@@ -782,13 +850,16 @@
"leaveSharedAlbum": "Tinggalkan album bersama?",
"leaveAlbum": "Tinggalkan album",
"photosAddedByYouWillBeRemovedFromTheAlbum": "Foto yang telah kamu tambahkan akan dihapus dari album ini",
"youveNoFilesInThisAlbumThatCanBeDeleted": "Anda tidak memiliki file di album ini yang dapat dihapus",
"youDontHaveAnyArchivedItems": "Kamu tidak memiliki item di arsip.",
"ignoredFolderUploadReason": "Sejumlah file di album ini tidak terunggah karena telah dihapus sebelumnya dari Ente.",
"resetIgnoredFiles": "Atur ulang file yang diabaikan",
"deviceFilesAutoUploading": "File yang ditambahkan ke album perangkat ini akan diunggah ke Ente secara otomatis.",
"turnOnBackupForAutoUpload": "Aktifkan pencadangan untuk mengunggah file yang ditambahkan ke folder ini ke Ente secara otomatis.",
"noHiddenPhotosOrVideos": "Tidak ada foto atau video tersembunyi",
"toHideAPhotoOrVideo": "Untuk menyembunyikan foto atau video",
"openTheItem": "• Buka item-nya",
"clickOnTheOverflowMenu": "• Klik pada menu overflow",
"click": "• Click",
"nothingToSeeHere": "Tidak ada apa-apa di sini! 👀",
"unarchiveAlbum": "Keluarkan album dari arsip",
@@ -796,6 +867,7 @@
"calculating": "Menghitung...",
"pleaseWaitDeletingAlbum": "Harap tunggu, sedang menghapus album",
"searchByExamples": "• Nama album (cth. \"Kamera\")\n• Jenis file (cth. \"Video\", \".gif\")\n• Tahun atau bulan (cth. \"2022\", \"Januari\")\n• Musim liburan (cth. \"Natal\")\n• Keterangan foto (cth. “#seru”)",
"youCanTrySearchingForADifferentQuery": "Anda dapat mencoba mencari dengan kata-kata yang berbeda",
"noResultsFound": "Tidak ditemukan hasil",
"addedBy": "Ditambahkan oleh {emailOrName}",
"loadingExifData": "Memuat data EXIF...",
@@ -804,6 +876,7 @@
"thisImageHasNoExifData": "Gambar ini tidak memiliki data exif",
"exif": "EXIF",
"noResults": "Tidak ada hasil",
"weDontSupportEditingPhotosAndAlbumsThatYouDont": "Kami belum mendukung pengeditan foto dan album yang bukan milik anda",
"failedToFetchOriginalForEdit": "Gagal memuat file asli untuk mengedit",
"close": "Tutup",
"setAs": "Pasang sebagai",
@@ -814,12 +887,19 @@
"pressAndHoldToPlayVideo": "Tekan dan tahan untuk memutar video",
"pressAndHoldToPlayVideoDetailed": "Tekan dan tahan gambar untuk memutar video",
"downloadFailed": "Gagal mengunduh",
"deduplicateFiles": "Hilangkan Duplikasi File",
"deselectAll": "Batalkan semua pilihan",
"reviewDeduplicateItems": "Silakan lihat dan hapus item yang merupakan duplikat.",
"clubByCaptureTime": "Kelompokkan berdasarkan waktu pengambilan",
"clubByFileName": "Kelompokkan berdasarkan nama berkas",
"count": "Jumlah",
"totalSize": "Ukuran total",
"longpressOnAnItemToViewInFullscreen": "Tekan lama pada item untuk melihat dalam layar penuh",
"decryptingVideo": "Mendekripsi video...",
"authToViewYourMemories": "Harap autentikasi untuk melihat kenanganmu",
"unlock": "Buka",
"freeUpSpace": "Bersihkan ruang",
"freeUpSpaceSaving": "{count, plural, =1 {Itu dapat dihapus dari perangkat untuk mengosongkan {formattedSize}} other {Itu dapat dihapus dari perangkat untuk mengosongkan {formattedSize}}}",
"filesBackedUpInAlbum": "{count, plural, other {{formattedNumber} file}} dalam album ini telah berhasil dicadangkan",
"@filesBackedUpInAlbum": {
"description": "Text to tell user how many files have been backed up in the album",
@@ -850,11 +930,24 @@
}
}
},
"@freeUpSpaceSaving": {
"description": "Text to tell user how much space they can free up by deleting items from the device"
},
"freeUpAccessPostDelete": "anda masih dapat mengakses {count, plural, =1 {itu} other {mereka}} di Ente selama anda memiliki langganan aktif",
"@freeUpAccessPostDelete": {
"placeholders": {
"count": {
"example": "1",
"type": "int"
}
}
},
"freeUpAmount": "Bersihkan {sizeInMBorGB}",
"thisEmailIsAlreadyInUse": "Email ini telah digunakan",
"incorrectCode": "Kode salah",
"authenticationFailedPleaseTryAgain": "Autentikasi gagal, silakan coba lagi",
"verificationFailedPleaseTryAgain": "Verifikasi gagal, silakan coba lagi",
"authenticating": "Autentikasi...",
"authenticationSuccessful": "Autentikasi berhasil!",
"incorrectRecoveryKey": "Kunci pemulihan salah",
"theRecoveryKeyYouEnteredIsIncorrect": "Kunci pemulihan yang kamu masukkan salah",
@@ -865,12 +958,35 @@
"sorryTheCodeYouveEnteredIsIncorrect": "Maaf, kode yang kamu masukkan salah",
"yourVerificationCodeHasExpired": "Kode verifikasi kamu telah kedaluwarsa",
"emailChangedTo": "Email diubah menjadi {newEmail}",
"verifying": "Memverifikasi...",
"disablingTwofactorAuthentication": "Menonaktifkan autentikasi dua langkah...",
"allMemoriesPreserved": "Semua kenangan terpelihara",
"loadingGallery": "Memuat galeri...",
"syncing": "Menyinkronkan...",
"encryptingBackup": "Mengenkripsi cadangan...",
"syncStopped": "Sinkronisasi terhenti",
"syncProgress": "{completed}/{total} memori tersimpan",
"uploadingMultipleMemories": "Menyimpan {count} memori...",
"@uploadingMultipleMemories": {
"description": "Text to tell user how many memories are being preserved",
"placeholders": {
"count": {
"type": "String"
}
}
},
"uploadingSingleMemory": "Menyimpan 1 memori...",
"@syncProgress": {
"description": "Text to tell user how many memories have been preserved",
"placeholders": {
"completed": {
"type": "String"
},
"total": {
"type": "String"
}
}
},
"archiving": "Mengarsipkan...",
"unarchiving": "Mengeluarkan dari arsip...",
"successfullyArchived": "Berhasil diarsipkan",
@@ -885,6 +1001,8 @@
"empty": "Kosongkan",
"couldNotFreeUpSpace": "Tidak dapat membersihkan ruang",
"permanentlyDeleteFromDevice": "Hapus dari perangkat secara permanen?",
"someOfTheFilesYouAreTryingToDeleteAre": "Beberapa file yang anda coba hapus hanya tersedia di perangkat anda dan tidak dapat dipulihkan jika dihapus",
"theyWillBeDeletedFromAllAlbums": "Mereka akan dihapus dari semua album.",
"someItemsAreInBothEnteAndYourDevice": "Sejumlah item tersimpan di Ente serta di perangkat ini.",
"selectedItemsWillBeDeletedFromAllAlbumsAndMoved": "Item terpilih akan dihapus dari semua album dan dipindahkan ke sampah.",
"theseItemsWillBeDeletedFromYourDevice": "Item ini akan dihapus dari perangkat ini.",
@@ -894,12 +1012,17 @@
"networkHostLookUpErr": "Tidak dapat terhubung dengan Ente, harap periksa pengaturan jaringan kamu dan hubungi dukungan jika masalah berlanjut.",
"networkConnectionRefusedErr": "Tidak dapat terhubung dengan Ente, silakan coba lagi setelah beberapa saat. Jika masalah berlanjut, harap hubungi dukungan.",
"cachedData": "Data cache",
"clearCaches": "Bersihkan cache",
"remoteImages": "Gambar jarak jauh",
"remoteVideos": "Video jarak jauh",
"remoteThumbnails": "Thumbnail jarak jauh",
"pendingSync": "Sinkronisasi tertunda",
"localGallery": "Galeri lokal",
"todaysLogs": "Log hari ini",
"viewLogs": "Lihat log",
"logsDialogBody": "Ini akan mengirimkan log untuk membantu kami memperbaiki masalah anda. Harap diperhatikan bahwa nama file akan disertakan untuk membantu melacak masalah pada file tertentu.",
"preparingLogs": "Menyiapkan log...",
"emailYourLogs": "Kirim log anda melalui email",
"pleaseSendTheLogsTo": "Silakan kirim log-nya ke \n{toEmail}",
"copyEmailAddress": "Salin alamat email",
"exportLogs": "Ekspor log",

View File

@@ -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"
}

View File

@@ -1665,5 +1665,11 @@
"moon": "月明かりの中",
"onTheRoad": "再び道で",
"food": "料理を楽しむ",
"pets": "毛むくじゃらな仲間たち"
"pets": "毛むくじゃらな仲間たち",
"cLTitle1": "類似画像",
"cLDesc1": "類似画像を検出する新しいML基盤システムを導入し、ライブラリをクリーンアップできます。設定 -> バックアップ -> 容量を空ける で利用可能",
"cLTitle2": "動画ストリーミングの強化",
"cLDesc2": "アプリから直接、動画のストリーム生成を手動でトリガーできるようになりました。また、動画のうち何パーセントがストリーミング用に処理されたかを表示する新しい動画ストリーミング設定画面も追加しました",
"cLTitle3": "パフォーマンスの改善",
"cLDesc3": "より良いキャッシュ使用とよりスムーズなスクロール体験を含む、複数の内部改善"
}

View File

@@ -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ę",
@@ -1818,5 +1810,16 @@
"brushColor": "Teptuko spalva",
"font": "Šriftas",
"background": "Fonas",
"align": "Lygiuoti"
}
"align": "Lygiuoti",
"similarImages": "Panašūs vaizdai",
"findSimilarImages": "Rasti panašų vaizdų",
"noSimilarImagesFound": "Panašių vaizdų nerasta",
"yourPhotosLookUnique": "Jūsų nuotraukos atrodo ypatingos",
"selectionOptions": "Pasirinkimo parinktys",
"deleteFiles": "Ištrinti failus",
"areYouSureDeleteFiles": "Ar tikrai norite ištrinti šiuos failus?",
"greatJob": "Puiku!",
"size": "Dydis",
"similarity": "Panašumas",
"processingLocally": "Apdorojama vietoje"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

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