Compare commits

..

189 Commits

Author SHA1 Message Date
a5xwin
46db03c07b Merge remote-tracking branch 'origin/main' into multiselect 2025-09-11 20:41:29 +05:30
a5xwin
b877b0a4cc added unpin option for mixed pin state multiselect 2025-09-11 20:40:52 +05:30
a5xwin
920b3b1931 added unpin option for mixed pin state multiselect 2025-09-11 20:39:17 +05:30
Neeraj
ad95b5bd2d [mob] Add album join option for internal users (#7147)
## Summary
• Adds album join toggle in manage links widget behind internal user
flag
• Dynamic permission levels: Viewers or Collaborators based on collect
setting

## Test plan
- [ ] Verify toggle only appears for internal users
- [ ] Test toggle functionality
- [ ] Confirm description changes based on collect setting
2025-09-11 17:37:12 +05:30
Neeraj
6b1757fc36 Update internal changes log with new entries 2025-09-11 17:16:01 +05:30
Neeraj
42527c0cd5 [mob] Add album join option for internal users
Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-11 16:53:25 +05:30
Neeraj
8810f88236 [infra] web-based log parser for support (#7144)
## Description

## Tests
2025-09-11 14:30:22 +05:30
Neeraj
e1423f2030 Add web-based log viewer for Ente application logs
This adds a comprehensive web-based log viewer that provides similar
functionality to the mobile log viewer, allowing analysis of log files
from customer support requests.

Features:
- Upload and parse ZIP files containing daily log files
- Advanced filtering by log level, logger name, process, and timeline
- Text search with wildcard support (logger:Service*)
- Interactive analytics with click-to-filter charts
- Modern UI using Ente's design system and Material-UI components
- Infinite scroll for performance with large log files
- Export functionality for filtered results
- Responsive design for desktop and mobile

Technical highlights:
- Client-side ZIP processing with JSZip
- Efficient log parsing supporting Ente's super_logging format
- Real-time filtering with optimized algorithms
- Memory-efficient rendering with virtual scrolling
- CSS custom properties for theming consistency

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-11 13:55:24 +05:30
Neeraj
c2ba7c56be Add process prefix filtering and improve log filter dialog UI (#7142) 2025-09-11 11:43:03 +05:30
Neeraj
93618117c5 [mob] Bump version v1.2.6 2025-09-11 11:41:02 +05:30
Neeraj
08c38086a5 Add process prefix filtering and improve log filter dialog UI
- Add process prefix filter section with user-friendly display names
- Move process filter above loggers in dialog layout
- Compact dialog design: reduce sizes, padding, and font sizes
- Optimize filter chip layout for better mobile experience

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-11 11:32:58 +05:30
Neeraj
a4762d68f1 [mob] LockExist error fix & log view imporvement (#7141)
## Description

## Tests
2025-09-11 09:29:35 +05:30
Neeraj Gupta
936c6f1b61 Clean up 2025-09-11 07:53:13 +05:30
Neeraj Gupta
cfada04396 feat(log_viewer): Enhance search, filters, and UI
- Add logger name filtering via search box with logger:name syntax
- Support wildcard patterns (logger:Auth* matches all loggers starting with Auth)
- Make logger cards in statistics page tappable for quick filtering
- Set default filters to show WARNING, SEVERE, SHOUT levels
- Improve Filter Dialog UI with modern design and better spacing
- Reduce search box size with smaller font and padding
- Use proper theme colors for buttons (FilledButton)
- Remove Processes section from filter dialog for simplicity

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-11 07:49:29 +05:30
Neeraj Gupta
25287c64f5 Update log_viewer docs to reflect simplified integration API
- Add prefix parameter documentation to LogViewer.initialize()
- Remove callback-based integration examples
- Simplify SuperLogging integration to direct initialization
- Update all code examples to use LogViewer.openViewer()
- Correct database entry limit from 2000 to 10000
- Clarify automatic log capture via Logger.root.onRecord

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-11 06:25:19 +05:30
Neeraj Gupta
168254ba42 Merge remote-tracking branch 'origin/main' into misc_fixes 2025-09-11 06:04:32 +05:30
Neeraj Gupta
05f7792012 [mob] Fix incorrect casting 2025-09-11 06:04:22 +05:30
Neeraj
d5f2b6456e [mob] Fix build (#7135)
## Description

## Tests
2025-09-10 20:43:21 +05:30
Neeraj Gupta
ec6692b68a Merge remote-tracking branch 'origin/main' into fixBuild 2025-09-10 19:59:35 +05:30
Neeraj
eead32ffe2 Update internal changes log with new entries (#7134)
## Description

## Tests
2025-09-10 19:59:26 +05:30
Neeraj
e90814c16e Merge branch 'main' into ua741-patch-2 2025-09-10 19:58:58 +05:30
Neeraj Gupta
dbe0bbc9dc [mob] Fix build error 2025-09-10 19:58:04 +05:30
Laurens Priem
bbea022aef [mob][photos] Add text embeddings cache service (#7130)
## Description

Add text embeddings cache service to prevent recomputes for:
- Memories
- Magic cache

## Tests

Tested in debug mode on my pixel phone.
2025-09-10 18:01:53 +05:30
Laurens Priem
92c4b325ca Merge branch 'main' into text_embeddings_cache 2025-09-10 17:51:51 +05:30
laurenspriem
bc66c1519a put db method preference in claude 2025-09-10 17:49:54 +05:30
Neeraj
1e804d4829 Update internal changes log with new entries 2025-09-10 17:47:03 +05:30
laurenspriem
3a8c95123e Add database method best practice guideline
Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-10 17:42:31 +05:30
laurenspriem
54ad3e4abb Simplify getRepeatedTextEmbeddingCache method
Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-10 17:38:42 +05:30
laurenspriem
8e29a9e26b update internal change 2025-09-10 17:34:03 +05:30
Neeraj
c82b829fe3 [mobile] Add debug option to enable database logging (#7133)
Add option for internal users to enable database logging in release
builds for debugging purposes.
2025-09-10 17:33:56 +05:30
Neeraj
1dbdb270b4 [mob][i] Allow internal users to enable db logging 2025-09-10 17:28:12 +05:30
Neeraj
1d1efc286f [mob][internal] Add QR code sharing feature for album links (#7132)
## Summary
- Add QR code sharing feature for album links behind internal user flag
- Integrate QR option in manage links and share collection pages  
- Auto-close dialog after share operation for better UX

## Implementation
- **New QrCodeDialogWidget**: Custom dialog with album name, QR code,
and ente branding
- **Share functionality**: Captures QR as image and shares with album
context
- **Feature gating**: Hidden behind `flagService.internalUser` for
internal testing
- **UI integration**: Available in both share collection page and manage
links page
- **Dependencies**: Added `qr_flutter: ^4.1.0` for QR generation

## Test Plan
-  QR code generation works for album URLs
-  Share functionality captures and exports QR as image
-  Dialog auto-closes after share operation
-  Feature properly hidden behind internal user flag
-  UI integrates seamlessly with existing sharing flow
-  Visual hierarchy: QR primary, album name secondary, branding
tertiary
2025-09-10 16:48:36 +05:30
Neeraj
dc500795a1 Add QR code sharing feature for album links
- Add QrCodeDialogWidget with album branding and share functionality
- Integrate QR code option in manage links and share collection pages
- Feature gated behind flagService.internalUser for testing
- QR codes include album name, scannable link, and ente branding
- Auto-close dialog after share operation for better UX
- Add qr_flutter dependency for QR code generation

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-10 16:31:09 +05:30
Neeraj
11afcd92af [server] Support for changing server port (#7131)
## Description
Ref: https://github.com/ente-io/ente/issues/7122

## Tests
Tested locally
2025-09-10 16:22:43 +05:30
Manav Rathi
f20c8caff0 [server] Improve support for idn domains (#7124)
## Description

## Tests
2025-09-10 16:17:12 +05:30
laurenspriem
c691b545a2 Remove unnecessary cache lock from text embeddings service
Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-10 15:39:15 +05:30
laurenspriem
edcec3277e format 2025-09-10 15:37:30 +05:30
laurenspriem
cda3a5b149 Simplify text embeddings cache to use only database cache
Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-10 15:35:58 +05:30
laurenspriem
cc769fdd5b Remove assets folder 2025-09-10 15:20:24 +05:30
laurenspriem
b74fe86e87 Merge branch 'main' into text_embeddings_cache 2025-09-10 15:18:29 +05:30
Neeraj Gupta
074f68146f [server] Support for changing server port 2025-09-10 14:47:06 +05:30
laurenspriem
e420d7b86f Add text embeddings cache service
Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-10 14:24:08 +05:30
Neeraj
68caa3f7c6 [mob] Add in-app log viewer for mobile debugging (#7129)
## Description
Introduces a comprehensive log viewer package for Flutter mobile apps
with:
- Real-time log viewing with filtering by level, logger name, and search
- SQLite-based storage with automatic log rotation (10k entries default)
- Timeline visualization and export functionality
- Integration with SuperLogging for seamless log capture

Only enabled in debug mode to avoid production impact.

## Tests



https://github.com/user-attachments/assets/badb2a4a-a9a2-4aec-b0ae-d825cc4fe23e
2025-09-10 13:53:43 +05:30
Neeraj
5e5d5f4aad Lint fixes for log_viewer 2025-09-10 13:36:39 +05:30
Neeraj
8713dd0707 Do null check before try block 2025-09-10 13:19:33 +05:30
Neeraj
102313f686 Clean up 2025-09-10 13:16:19 +05:30
Neeraj
7ef9fdcaaa Add in-app log viewer for mobile debugging
Introduces a comprehensive log viewer package for Flutter mobile apps with:
- Real-time log viewing with filtering by level, logger name, and search
- SQLite-based storage with automatic log rotation (10k entries default)
- Timeline visualization and export functionality
- Integration with SuperLogging for seamless log capture

Only enabled in debug mode to avoid production impact.

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-10 13:06:05 +05:30
Manav Rathi
d902733809 [mob][photos] symlink for agents.md (#7128)
## Description

symlink for [agents.md](https://agents.md)
2025-09-10 11:59:51 +05:30
laurenspriem
0ef990de5a Make CLAUDE.md agent-agnostic, add AGENTS.md symlink
Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-10 11:22:16 +05:30
Manav Rathi
7722c4e16b Fix command to reload Caddy (#7125)
```
$ sudo systemctl caddy reload
Unknown command verb 'caddy', did you mean 'cat'?
```
2025-09-10 09:23:46 +05:30
Hans Lemuet
6f5fdfb7b7 Fix command to reload Caddy
$ sudo systemctl caddy reload
Unknown command verb 'caddy', did you mean 'cat'?
2025-09-10 02:14:56 +02:00
Neeraj Gupta
135124a487 Improve err handling 2025-09-10 05:07:31 +05:30
Neeraj Gupta
d3c53794cf Add alert for exactDomain mismatch 2025-09-10 04:52:20 +05:30
Neeraj Gupta
270cee8b09 [server] Support for idn domain 2025-09-10 04:40:27 +05:30
Neeraj
9b05cc8c23 [server] Minor improvements in link middleware (#7104)
## Description

## Tests
2025-09-10 04:25:23 +05:30
Manav Rathi
5b6c3e1b6e [destkop] Update typo in translation (#7118)
See: https://github.com/ente-io/ente/pull/5546#issuecomment-3268874821

Updated the strings in crowdin by `gh workflow run
web-crowdin-push-both.yml`
2025-09-09 17:41:34 +05:30
Manav Rathi
636793d5b1 [destkop] Update typo in translation
See: https://github.com/ente-io/ente/pull/5546#issuecomment-3268874821
2025-09-09 17:32:18 +05:30
Manav Rathi
700e52d11a [web] Harden workflows (#7114) 2025-09-09 13:30:32 +05:30
Manav Rathi
82c7d1865c Update 2025-09-09 12:49:08 +05:30
Manav Rathi
f08ee15cea [web] Harden workflows 2025-09-09 12:00:56 +05:30
Laurens Priem
901bfc945e [mob][photos] Fix copy paste mistake in claude.md (#7109)
## Description

Fix copy paste mistake in claude.md
2025-09-08 16:48:46 +05:30
laurenspriem
6c25b094be Fix copy paste mistake in claude.md 2025-09-08 16:47:21 +05:30
a5xwin
3026ec5be3 fixed bug causing incorrect code count 2025-09-08 16:26:55 +05:30
Laurens Priem
4f5af8dcfa [mob][photos] Lower cluster size threshold on discover page (#7105)
## Description

Lower cluster size threshold on discover page
2025-09-08 15:15:12 +05:30
laurenspriem
8079d44c68 Lower cluster size threshold on discover page 2025-09-08 15:05:22 +05:30
Neeraj Gupta
575314c8a1 [server] Relax origin check for localhost for dev 2025-09-08 14:37:22 +05:30
Neeraj Gupta
2684f9ce11 [server] whitelist shared url 2025-09-08 14:33:31 +05:30
Neeraj
cd5582219c [mob] pin in_app_purchase to v3.2.1 (#7103)
## Description

Ref: https://github.com/flutter/flutter/issues/169335

## Tests
Tested via TF.
2025-09-08 14:27:19 +05:30
Neeraj Gupta
69332c78ad Update daily changelog 2025-09-08 14:26:00 +05:30
Aman Raj Singh Mourya
cba30e386d [locker] Update Locker asset & icons (#7102)
## Description
Add background images, svgs for locker.
Update imports & add `flutter_lints` dependencies for
`mobile/pubspec.yaml`
2025-09-08 13:49:57 +05:30
Neeraj Gupta
7663e76deb [mob] pin in_app_purchase to v3.2.1 2025-09-08 13:32:09 +05:30
AmanRajSinghMourya
697d6f854d Add lockscreen background photos 2025-09-08 13:02:42 +05:30
AmanRajSinghMourya
7aadb54ef1 Add icons directory to asset list in pubspec.yaml 2025-09-08 13:02:21 +05:30
AmanRajSinghMourya
2a2443efea Fix dependencies of mobile, add flutter_lints 2025-09-08 13:02:08 +05:30
AmanRajSinghMourya
d2bc2627a3 Move to /icons 2025-09-08 13:01:35 +05:30
AmanRajSinghMourya
b1971810fb Fix imports 2025-09-08 12:20:16 +05:30
AmanRajSinghMourya
bd25af2b4b Assets for legacy 2025-09-08 12:20:06 +05:30
Neeraj
833b4656fe [mob][photos] Dart format (#7101)
## Description

- Formats the flutter code in `/photos`
- Adds format instructions to claude.md
2025-09-08 11:38:42 +05:30
laurenspriem
315c4ae6b7 Tell Claude to format after every code change 2025-09-08 11:03:46 +05:30
laurenspriem
49d9b3c928 dart format . 2025-09-08 11:00:47 +05:30
a5xwin
440bafa56a Merge remote-tracking branch 'origin/main' into multiselect 2025-09-07 15:02:01 +05:30
a5xwin
87c236b629 added multiselect feature 2025-09-07 14:59:53 +05:30
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
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
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
AmanRajSinghMourya
2bdf62c490 Add melos support for photos/plugins 2025-09-02 14:08:04 +05:30
AmanRajSinghMourya
2cab943647 Organize imports 2025-09-02 13:41:52 +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
AmanRajSinghMourya
a9d5773b9a Fix sync after leaving collection 2025-08-30 15:12:37 +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
AmanRajSinghMourya
6775faf0d0 Fix naming 2025-08-29 12:43:30 +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
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
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
AmanRajSinghMourya
bdfe363066 Minor UI fix 2025-08-26 23:46:38 +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
AmanRajSinghMourya
158b48e4dc Add SharingNotPermittedForFreeAccountsError error 2025-08-24 23:58:22 +05:30
a5xwin
fe732f2778 Merge remote-tracking branch 'origin/main' into autobackup 2025-08-24 22:58:36 +05:30
a5xwin
ca8a067966 added support to view backup path dynamically & improved ui flow 2025-08-24 22:57:35 +05:30
a5xwin
5e3a779925 Merge remote-tracking branch 'origin/main' into autobackup 2025-08-22 19:32:03 +05:30
a5xwin
d1b06abada Improve backup flow: add user controlled path dialog, better default path, and update text casing 2025-08-22 19:21:05 +05:30
a5xwin
9e70dc4312 Merge remote-tracking branch 'origin/main' into autobackup 2025-08-19 15:54:01 +05:30
a5xwin
541d71f65c added encryption feature to backups + refined backup logic 2025-08-19 15:07:23 +05:30
a5xwin
d8fc369a21 Merge remote-tracking branch 'origin/main' into autobackup 2025-08-17 00:15:50 +05:30
a5xwin
8efbebe9c4 Merge remote-tracking branch 'origin/main' into autobackup 2025-08-16 18:36:43 +05:30
a5xwin
a7300b7ac7 Restore local changes from stash 2025-08-15 16:07:51 +05:30
a5xwin
9224cea96f Fixed linting issues 2025-08-14 00:45:11 +05:30
a5xwin
9fbc618d69 Updated pubspec.lock regeneration 2025-08-14 00:02:04 +05:30
a5xwin
4614428f76 Resolved pubspec.lock conflict by taking main version 2025-08-13 23:36:49 +05:30
a5xwin
6fde4ee45f implemented an auto backup feature 2025-08-13 23:25:48 +05:30
371 changed files with 38056 additions and 7577 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -0,0 +1,167 @@
# Ente Log Viewer
A web-based log viewer for analyzing Ente application logs. This tool provides similar functionality to the mobile log viewer, allowing you to upload, filter, and analyze log files from customer support requests.
## Features
### 📁 File Upload
- Drag and drop ZIP files containing log files
- Automatic extraction and parsing of log files
- Support for daily log files format (YYYY-M-D.log)
### 🔍 Search and Filtering
- **Text Search**: Search through log messages, logger names, and error content
- **Logger Filtering**: Use `logger:ServiceName` syntax to filter by specific loggers
- **Wildcard Support**: Use `logger:Auth*` to match all loggers starting with "Auth"
- **Level Filtering**: Filter by log levels (SEVERE, WARNING, INFO, etc.)
- **Process Filtering**: Filter by foreground/background processes
- **Timeline Filtering**: Filter by date/time ranges
### 📊 Analytics
- Logger statistics showing most active components
- Log level distribution charts
- Click-to-filter from analytics charts
### 🎨 UI Features
- **Color-coded log levels**: Red for SEVERE, orange for WARNING, etc.
- **Process indicators**: Visual distinction between foreground and background processes
- **Active filter chips**: Visual indication of applied filters with easy removal
- **Log detail view**: Click any log entry for detailed information
- **Sort options**: Sort by newest first or oldest first
- **Responsive design**: Works on desktop and mobile devices
### 📤 Export
- Export filtered logs as text files
- Copy individual log entries to clipboard
- Maintain original formatting and error details
## Usage
### Starting the Application
1. **Local Development**:
```bash
cd infra/experiments/logs-viewer
python3 -m http.server 8080
```
Open http://localhost:8080 in your browser
2. **Upload Log Files**:
- Drag and drop a ZIP file containing `.log` files
- Or click "Choose ZIP File" to browse for files
### Log Format Support
The viewer understands the Ente log format as generated by `super_logging.dart`:
```
[process] [loggerName] [LEVEL] [timestamp] message
```
**Examples**:
- `[bg] [SyncService] [INFO] [2025-08-24 01:36:03.677678] Syncing started`
- `[CollectionsService] [WARNING] [2025-08-24 01:36:04.123456] Connection failed`
**Multi-line Error Support**:
- Automatically parses `` error detail lines
- Extracts stack traces and error IDs
- Handles inline error messages and exceptions
### Filtering Examples
- **Search by text**: `connection failed`
- **Filter by logger**: `logger:SyncService`
- **Multiple loggers**: `logger:Sync* logger:Collection*`
- **Combined search**: `logger:Auth* login failed`
### Keyboard Shortcuts
- **Search**: Click search bar or start typing
- **Clear search**: Click X button or clear the input
- **Sort toggle**: Click sort arrow button
- **Filter dialog**: Click filter button
## Technical Details
### Supported Log Levels
- **SHOUT**: Purple - Highest priority
- **SEVERE**: Red - Errors and exceptions
- **WARNING**: Orange - Warning conditions
- **INFO**: Blue - Informational messages
- **CONFIG**: Green - Configuration messages
- **FINE/FINER/FINEST**: Gray - Debug messages
### Process Types
- **Foreground**: Main app processes
- **Background (bg)**: Background tasks
- **Firebase Background (fbg)**: Firebase-related background processes
### Performance
- Lazy loading: Only renders visible log entries
- Efficient filtering: Client-side filtering with optimized algorithms
- Memory management: Handles large log files (tested with 100k+ entries)
## Sample Log Files
For testing, you can use any ZIP file containing `.log` files from Ente mobile app logs.
## Development
### File Structure
```
logs-viewer/
├── index.html # Main HTML structure
├── styles.css # CSS styling
├── script.js # JavaScript logic
├── README.md # This documentation
└── references/ # UI reference screenshots
```
### Key JavaScript Classes
- `LogViewer`: Main application class
- Log parsing logic in `parseLogFile()` and `parseLogLine()`
- Filter management in `applyCurrentFilter()`
- UI rendering in `renderLogs()` and `createLogEntryHTML()`
### Adding Features
1. **New Filter Types**: Extend `currentFilter` object and `matchesFilter()` method
2. **New Log Formats**: Update parsing patterns in `parseLogLine()`
3. **UI Components**: Add HTML elements and CSS classes, wire up in `initializeEventListeners()`
## Troubleshooting
### Common Issues
1. **ZIP file not loading**:
- Ensure ZIP contains `.log` files
- Check browser console for errors
- Try a smaller file first
2. **Logs not parsing correctly**:
- Check log format matches expected pattern
- Look for console warnings about parsing failures
- Verify timestamp format is correct
3. **Performance issues with large files**:
- The viewer handles pagination (100 logs at a time)
- Use filters to reduce the dataset
- Close other browser tabs to free memory
4. **Search not working**:
- Check for typos in logger names
- Use wildcard syntax: `logger:Service*`
- Search is case-insensitive for message content
### Browser Compatibility
- **Recommended**: Chrome 80+, Firefox 75+, Safari 13+
- **Required**: ES6 support, Fetch API, File API
- **Dependencies**: JSZip library for ZIP file handling
## Future Enhancements
- Real-time log streaming
- Advanced regex search
- Log correlation and grouping
- Performance metrics dashboard
- Dark theme support
- Export to JSON/CSV formats

View File

@@ -0,0 +1,110 @@
/* Ente Theme Variables based on web/packages/base/components/utils/theme.ts */
:root {
/* Light theme colors */
--ente-color-accent-photos: #1db954;
--ente-color-accent-auth: #9610d6;
--ente-color-accent-locker: #5ba8ff;
/* Background colors */
--ente-background-default: #fff;
--ente-background-paper: #fff;
--ente-background-paper2: #fbfbfb;
--ente-background-search: #f3f3f3;
/* Text colors */
--ente-text-base: #000;
--ente-text-muted: rgba(0, 0, 0, 0.60);
--ente-text-faint: rgba(0, 0, 0, 0.50);
/* Fill colors */
--ente-fill-base: #000;
--ente-fill-muted: rgba(0, 0, 0, 0.12);
--ente-fill-faint: rgba(0, 0, 0, 0.04);
--ente-fill-faint-hover: rgba(0, 0, 0, 0.08);
--ente-fill-fainter: rgba(0, 0, 0, 0.02);
/* Stroke colors */
--ente-stroke-base: #000;
--ente-stroke-muted: rgba(0, 0, 0, 0.24);
--ente-stroke-faint: rgba(0, 0, 0, 0.12);
--ente-stroke-fainter: rgba(0, 0, 0, 0.06);
/* Shadow */
--ente-shadow-paper: 0px 0px 10px rgba(0, 0, 0, 0.25);
--ente-shadow-menu: 0px 0px 6px rgba(0, 0, 0, 0.16), 0px 3px 6px rgba(0, 0, 0, 0.12);
--ente-shadow-button: 0px 4px 4px rgba(0, 0, 0, 0.25);
/* Fixed colors */
--ente-success: #1db954;
--ente-warning: #ffc107;
--ente-danger: #ea3f3f;
--ente-danger-dark: #f53434;
--ente-danger-light: #ff6565;
/* Secondary colors */
--ente-secondary-main: #f5f5f5;
--ente-secondary-hover: #e9e9e9;
/* Action colors */
--ente-action-hover: rgba(0, 0, 0, 0.08);
--ente-action-disabled: rgba(0, 0, 0, 0.50);
/* Typography */
--ente-font-family: "Inter Variable", sans-serif;
--ente-font-weight-regular: 500;
--ente-font-weight-medium: 600;
--ente-font-weight-bold: 700;
/* Border radius */
--ente-border-radius: 8px;
--ente-border-radius-small: 4px;
/* Spacing */
--ente-spacing-xs: 4px;
--ente-spacing-sm: 8px;
--ente-spacing-md: 12px;
--ente-spacing-lg: 16px;
--ente-spacing-xl: 24px;
}
/* Dark theme */
@media (prefers-color-scheme: dark) {
:root {
/* Background colors */
--ente-background-default: #000;
--ente-background-paper: #1b1b1b;
--ente-background-paper2: #252525;
--ente-background-search: #1b1b1b;
/* Text colors */
--ente-text-base: #fff;
--ente-text-muted: rgba(255, 255, 255, 0.70);
--ente-text-faint: rgba(255, 255, 255, 0.50);
/* Fill colors */
--ente-fill-base: #fff;
--ente-fill-muted: rgba(255, 255, 255, 0.16);
--ente-fill-faint: rgba(255, 255, 255, 0.12);
--ente-fill-faint-hover: rgba(255, 255, 255, 0.16);
--ente-fill-fainter: rgba(255, 255, 255, 0.05);
/* Stroke colors */
--ente-stroke-base: #fff;
--ente-stroke-muted: rgba(255, 255, 255, 0.24);
--ente-stroke-faint: rgba(255, 255, 255, 0.16);
--ente-stroke-fainter: rgba(255, 255, 255, 0.12);
/* Shadow */
--ente-shadow-paper: 0px 2px 12px rgba(0, 0, 0, 0.75);
--ente-shadow-menu: 0px 0px 6px rgba(0, 0, 0, 0.50), 0px 3px 6px rgba(0, 0, 0, 0.25);
--ente-shadow-button: 0px 4px 4px rgba(0, 0, 0, 0.75);
/* Secondary colors */
--ente-secondary-main: #2b2b2b;
--ente-secondary-hover: #373737;
/* Action colors */
--ente-action-hover: rgba(255, 255, 255, 0.16);
--ente-action-disabled: rgba(255, 255, 255, 0.50);
}
}

View File

@@ -0,0 +1,218 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Ente Log Viewer</title>
<!-- Material-UI CSS -->
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" />
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@mui/material@latest/dist/index.css" />
<!-- Ente theme -->
<link rel="stylesheet" href="ente-theme.css">
<!-- Custom styles -->
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div class="app">
<!-- Header -->
<header class="header">
<div class="header-content">
<h1>📋 Ente Log Viewer</h1>
<div class="header-actions">
<button id="filter-btn" class="mui-icon-btn" title="Filter logs">
<span class="material-icons">filter_list</span>
<span class="filter-count" id="filter-count" style="display: none;"></span>
</button>
<button id="sort-btn" class="mui-icon-btn" title="Sort order">
<span class="material-icons">arrow_downward</span>
</button>
<div class="dropdown">
<button class="mui-icon-btn dropdown-toggle" title="More actions">
<span class="material-icons">more_vert</span>
</button>
<div class="dropdown-menu mui-menu">
<button id="analytics-btn" class="dropdown-item mui-menu-item">
<span class="material-icons">analytics</span>
<span>Analytics</span>
</button>
<button id="export-btn" class="dropdown-item mui-menu-item">
<span class="material-icons">download</span>
<span>Export Logs</span>
</button>
<button id="clear-btn" class="dropdown-item mui-menu-item danger">
<span class="material-icons">delete</span>
<span>Clear Logs</span>
</button>
</div>
</div>
</div>
</div>
</header>
<!-- Upload Section -->
<div class="upload-section" id="upload-section">
<div class="upload-area" id="upload-area">
<div class="upload-content">
<div class="upload-icon">📁</div>
<h2>Upload Log Files</h2>
<p>Drag and drop a zip file containing log files, or click to browse</p>
<input type="file" id="file-input" accept=".zip" hidden>
<button id="browse-btn" class="primary-btn">Choose ZIP File</button>
</div>
</div>
</div>
<!-- Main Content -->
<div class="main-content" id="main-content" style="display: none;">
<!-- Search Bar -->
<div class="search-section">
<div class="search-bar mui-search-container">
<div class="mui-textfield">
<input type="text" id="search-input" placeholder="Search logs... (try 'logger:SyncService' or text search)" class="mui-input" />
<span class="mui-search-icon material-icons">search</span>
<button id="clear-search" class="mui-clear-btn" style="display: none;">
<span class="material-icons">clear</span>
</button>
</div>
</div>
</div>
<!-- Timeline Filter -->
<div class="timeline-section" id="timeline-section" style="display: none;">
<div class="timeline-header">
<span class="material-icons">timeline</span>
<span>Timeline Filter</span>
<button id="timeline-toggle" class="mui-icon-btn timeline-btn">
<span class="material-icons">timeline</span>
</button>
</div>
<div class="timeline-controls" id="timeline-controls" style="display: none;">
<div class="timeline-range">
<div class="mui-textfield">
<input type="datetime-local" id="start-time" class="mui-input" />
</div>
<span class="range-separator">to</span>
<div class="mui-textfield">
<input type="datetime-local" id="end-time" class="mui-input" />
</div>
<button id="reset-timeline" class="mui-button secondary">
<span class="material-icons">refresh</span>
<span>Reset</span>
</button>
</div>
</div>
</div>
<!-- Active Filters -->
<div class="active-filters" id="active-filters" style="display: none;">
<div class="filter-chips" id="filter-chips"></div>
</div>
<!-- Log Stats -->
<div class="log-stats" id="log-stats">
<span id="log-count">0 logs loaded</span>
<span id="filtered-count"></span>
</div>
<!-- Log List -->
<div class="log-list-container">
<div class="log-list" id="log-list">
<!-- Log entries will be populated here -->
</div>
<div class="loading" id="loading" style="display: none;">Loading...</div>
<div class="load-more" id="load-more" style="display: none;">
<button class="secondary-btn">Load More</button>
</div>
</div>
</div>
</div>
<!-- Filter Dialog -->
<div class="dialog-overlay" id="filter-dialog" style="display: none;">
<div class="dialog">
<div class="dialog-header">
<h2>Filter Logs</h2>
<button class="close-btn" id="close-filter"></button>
</div>
<div class="dialog-content">
<!-- Log Levels -->
<div class="filter-section">
<h3>Log Levels</h3>
<div class="level-chips" id="level-chips">
<!-- Level chips will be populated here -->
</div>
</div>
<!-- Process -->
<div class="filter-section">
<h3>Process</h3>
<div class="process-list" id="process-list">
<!-- Process checkboxes will be populated here -->
</div>
</div>
<!-- Loggers -->
<div class="filter-section">
<h3>Loggers</h3>
<div class="logger-list" id="logger-list">
<!-- Logger checkboxes will be populated here -->
</div>
</div>
</div>
<div class="dialog-actions">
<button id="clear-filters" class="secondary-btn">Clear All</button>
<button id="cancel-filter" class="secondary-btn">Cancel</button>
<button id="apply-filters" class="primary-btn">Apply</button>
</div>
</div>
</div>
<!-- Analytics Dialog -->
<div class="dialog-overlay" id="analytics-dialog" style="display: none;">
<div class="dialog">
<div class="dialog-header">
<h2>Logger Analytics</h2>
<button class="close-btn" id="close-analytics"></button>
</div>
<div class="dialog-content">
<div id="analytics-content">
<!-- Analytics charts will be populated here -->
</div>
</div>
<div class="dialog-actions">
<button id="close-analytics-btn" class="secondary-btn">Close</button>
</div>
</div>
</div>
<!-- Log Detail Dialog -->
<div class="dialog-overlay" id="detail-dialog" style="display: none;">
<div class="dialog large">
<div class="dialog-header">
<h2>Log Details</h2>
<button class="close-btn" id="close-detail"></button>
</div>
<div class="dialog-content">
<div id="detail-content">
<!-- Log details will be populated here -->
</div>
</div>
<div class="dialog-actions">
<button id="copy-log" class="secondary-btn">Copy</button>
<button id="close-detail-btn" class="secondary-btn">Close</button>
</div>
</div>
</div>
<!-- Material-UI JavaScript -->
<script src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
<script src="https://unpkg.com/@mui/material@latest/umd/material-ui.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
<script src="script.js"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -11,6 +11,7 @@
"setupFirstAccount": "Setup your first account",
"importScanQrCode": "Scan a QR Code",
"qrCode": "QR Code",
"qr": "QR",
"importEnterSetupKey": "Enter a setup key",
"importAccountPageTitle": "Enter account details",
"secretCanNotBeEmpty": "Secret can not be empty",
@@ -139,6 +140,7 @@
"existingUser": "Existing User",
"newUser": "New to Ente",
"delete": "Delete",
"addTag": "Add tag",
"enterYourPasswordHint": "Enter your password",
"forgotPassword": "Forgot password",
"oops": "Oops",
@@ -527,5 +529,28 @@
"errorInvalidQRCodeBody": "The scanned QR code is not a valid 2FA account.",
"errorNoQRCode": "No QR code found",
"errorGenericTitle": "An Error Occurred",
"errorGenericBody": "An unexpected error occurred while importing."
"errorGenericBody": "An unexpected error occurred while importing.",
"localBackupSettingsTitle": "Local backup",
"localBackupSidebarTitle": "Local backup",
"enableAutomaticBackups": "Enable automatic backups",
"backupDescription": "This will automatically backup your data to an on-device location. Backups are updated whenever entries are added, edited or deleted",
"currentLocation": "Current backup location:",
"securityNotice": "Security notice",
"backupSecurityNotice": "This encrypted backup holds your 2FA keys. If lost, you may not be able to recover your accounts. Keep it safe!",
"locationUpdatedAndBackupCreated": "Location updated and initial backup created!",
"initialBackupCreated": "Initial backup created!",
"passwordTooShort": "Password must be at least 8 characters long.",
"noDefaultBackupFolder": "Could not create default backup folder.",
"backupLocationChoiceDescription": "Where do you want to save your backups?",
"chooseBackupLocation": "Choose a backup location",
"loadDefaultLocation": "Loading default location...",
"couldNotDetermineLocation":"Could not determine location...",
"saveAction":"Save",
"saveBackup":"Save backup",
"changeLocation": "Change location",
"changeCurrentLocation": "Change current location",
"done": "Done",
"addNew": "Add new",
"selected": "selected",
"moveMultipleToTrashMessage": "Are you sure you want to move {count} item(s) to the trash?"
}

View File

@@ -54,9 +54,14 @@ class ViewQrPage extends StatelessWidget {
const SizedBox(
width: 10,
),
Text(
code?.account ?? '',
style: enteTextTheme.largeBold,
Expanded(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Text(
code?.account ?? '',
style: enteTextTheme.largeBold,
),
),
),
],
),
@@ -73,9 +78,14 @@ class ViewQrPage extends StatelessWidget {
const SizedBox(
width: 10,
),
Text(
code?.issuer ?? '',
style: enteTextTheme.largeBold,
Expanded(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Text(
code?.issuer ?? '',
style: enteTextTheme.largeBold,
),
),
),
],
),

View File

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

View File

@@ -14,6 +14,31 @@ class CodeDisplayStore {
late CodeStore _codeStore;
final ValueNotifier<bool> isSelectionModeActive = ValueNotifier(false);
final ValueNotifier<Set<String>> selectedCodeIds = ValueNotifier(<String>{});
// toggles the selection status of a code
void toggleSelection(String codeId){
final newSelection = Set<String>.from(selectedCodeIds.value);
if(newSelection.contains(codeId)){
newSelection.remove(codeId);
}
else{
newSelection.add(codeId);
}
selectedCodeIds.value = newSelection; //if we selected atleast one code, then we're in selection mode.. else: exit selection mode
isSelectionModeActive.value = newSelection.isNotEmpty;
}
//method to clear the entire selection
void clearSelection(){
selectedCodeIds.value = <String>{};
isSelectionModeActive.value = false;
}
Future<void> init() async {
_codeStore = CodeStore.instance;
}

View File

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

View File

@@ -10,10 +10,10 @@ import 'package:ente_auth/models/code.dart';
import 'package:ente_auth/onboarding/view/setup_enter_secret_key_page.dart';
import 'package:ente_auth/onboarding/view/view_qr_page.dart';
import 'package:ente_auth/services/preference_service.dart';
import 'package:ente_auth/store/code_display_store.dart';
import 'package:ente_auth/store/code_store.dart';
import 'package:ente_auth/theme/ente_theme.dart';
import 'package:ente_auth/ui/code_timer_progress.dart';
import 'package:ente_auth/ui/components/bottom_action_bar_widget.dart';
import 'package:ente_auth/ui/components/models/button_type.dart';
import 'package:ente_auth/ui/share/code_share.dart';
import 'package:ente_auth/ui/utils/icon_utils.dart';
@@ -103,7 +103,6 @@ class _CodeWidgetState extends State<CodeWidget> {
@override
Widget build(BuildContext context) {
ignorePin = widget.sortKey != null && widget.sortKey == CodeSortKey.manual;
final colorScheme = getEnteColorScheme(context);
if (isMaskingEnabled != PreferenceService.instance.shouldHideCodes()) {
isMaskingEnabled = PreferenceService.instance.shouldHideCodes();
_hideCode = isMaskingEnabled;
@@ -118,96 +117,110 @@ class _CodeWidgetState extends State<CodeWidget> {
}
final l10n = context.l10n;
Widget getCardContents(AppLocalizations l10n) {
return Stack(
Widget getCardContents(AppLocalizations l10n, {required bool isSelected}) {
final colorScheme = getEnteColorScheme(context);
return Stack(
children: [
if (!ignorePin && widget.code.isPinned)
Align(
alignment: Alignment.topRight,
child: CustomPaint(
painter: PinBgPainter(
color: colorScheme.pinnedBgColor,
),
size: widget.isCompactMode
? const Size(24, 24)
: const Size(39, 39),
),
),
if (widget.code.isTrashed && kDebugMode)
Align(
alignment: Alignment.topLeft,
child: CustomPaint(
painter: PinBgPainter(
color: colorScheme.warning700,
),
size: const Size(39, 39),
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
if (!ignorePin && widget.code.isPinned)
Align(
alignment: Alignment.topRight,
child: CustomPaint(
painter: PinBgPainter(
color: colorScheme.pinnedBgColor,
),
size: widget.isCompactMode
? const Size(24, 24)
: const Size(39, 39),
),
if (widget.code.type.isTOTPCompatible)
CodeTimerProgress(
key: ValueKey('period_${widget.code.period}'),
period: widget.code.period,
isCompactMode: widget.isCompactMode,
timeOffsetInMilliseconds:
PreferenceService.instance.timeOffsetInMilliSeconds(),
),
if (widget.code.isTrashed && kDebugMode)
Align(
alignment: Alignment.topLeft,
child: CustomPaint(
painter: PinBgPainter(
color: colorScheme.warning700,
),
size: const Size(39, 39),
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
widget.isCompactMode
? const SizedBox(height: 4)
: const SizedBox(height: 28),
Row(
children: [
if (widget.code.type.isTOTPCompatible)
CodeTimerProgress(
key: ValueKey('period_${widget.code.period}'),
period: widget.code.period,
isCompactMode: widget.isCompactMode,
timeOffsetInMilliseconds:
PreferenceService.instance.timeOffsetInMilliSeconds(),
_shouldShowLargeIcon ? _getIcon() : const SizedBox.shrink(),
Expanded(
child: Column(
children: [
_getTopRow(isSelected: isSelected),
widget.isCompactMode
? const SizedBox.shrink()
: const SizedBox(height: 4),
_getBottomRow(l10n),
],
),
widget.isCompactMode
? const SizedBox(height: 4)
: const SizedBox(height: 28),
Row(
children: [
_shouldShowLargeIcon ? _getIcon() : const SizedBox.shrink(),
Expanded(
child: Column(
children: [
_getTopRow(),
widget.isCompactMode
? const SizedBox.shrink()
: const SizedBox(height: 4),
_getBottomRow(l10n),
],
),
),
],
),
widget.isCompactMode
? const SizedBox(height: 4)
: const SizedBox(height: 32),
],
),
if (!ignorePin && widget.code.isPinned) ...[
Align(
alignment: Alignment.topRight,
child: Padding(
padding: widget.isCompactMode
? const EdgeInsets.only(right: 4, top: 4)
: const EdgeInsets.only(right: 6, top: 6),
child: SvgPicture.asset(
"assets/svg/pin-card.svg",
width: widget.isCompactMode ? 8 : null,
height: widget.isCompactMode ? 8 : null,
),
),
),
],
widget.isCompactMode
? const SizedBox(height: 4)
: const SizedBox(height: 32),
],
);
}
),
if (!ignorePin && widget.code.isPinned) ...[
Align(
alignment: Alignment.topRight,
child: Padding(
padding: widget.isCompactMode
? const EdgeInsets.only(right: 4, top: 4)
: const EdgeInsets.only(right: 6, top: 6),
child: SvgPicture.asset(
"assets/svg/pin-card.svg",
width: widget.isCompactMode ? 8 : null,
height: widget.isCompactMode ? 8 : null,
),
),
),
],
],
);
}
Widget clippedCard(AppLocalizations l10n) {
final colorScheme = getEnteColorScheme(context);
return ValueListenableBuilder<Set<String>>(
valueListenable: CodeDisplayStore.instance.selectedCodeIds,
builder: (context, selectedIds, child) {
final isSelected = selectedIds.contains(widget.code.secret);
Widget clippedCard(AppLocalizations l10n) {
return Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: Theme.of(context).colorScheme.codeCardBackgroundColor,
color: isSelected
? colorScheme.primary400.withValues(alpha: 0.08)
: Theme.of(context).colorScheme.codeCardBackgroundColor,
//add purple overlay when selected
border: isSelected
? Border.all(color: colorScheme.primary400, width: 2)
: null,
boxShadow:
widget.code.isPinned ? colorScheme.pinnedCardBoxShadow : [],
(widget.code.isPinned && !isSelected) ? colorScheme.pinnedCardBoxShadow : [],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
borderRadius: BorderRadius.circular(6),
child: Material(
color: Colors.transparent,
child: InkWell(
@@ -215,7 +228,12 @@ class _CodeWidgetState extends State<CodeWidget> {
borderRadius: BorderRadius.circular(10),
),
onTap: () {
_copyCurrentOTPToClipboard();
final store = CodeDisplayStore.instance;
if (store.isSelectionModeActive.value) {
store.toggleSelection(widget.code.secret);
} else {
_copyCurrentOTPToClipboard();
}
},
onDoubleTap: isMaskingEnabled
? () {
@@ -229,30 +247,16 @@ class _CodeWidgetState extends State<CodeWidget> {
onLongPress: widget.isReordering
? null
: () {
showModalBottomSheet(
context: context,
builder: (_) {
return BottomActionBarWidget(
code: widget.code,
showPin: !ignorePin,
onEdit: () => _onEditPressed(true),
onShare: () => _onSharePressed(true),
onPin: () => _onPinPressed(true),
onTrashed: () => _onTrashPressed(true),
onDelete: () => _onDeletePressed(true),
onRestore: () => _onRestoreClicked(true),
onShowQR: () => _onShowQrPressed(true),
onCancel: () => Navigator.of(context).pop(),
);
},
);
CodeDisplayStore.instance.toggleSelection(widget.code.secret);
},
child: getCardContents(l10n),
child: getCardContents(l10n, isSelected: isSelected),
),
),
),
);
}
},
);
}
return Container(
margin: widget.isCompactMode
@@ -273,7 +277,7 @@ class _CodeWidgetState extends State<CodeWidget> {
),
if (!widget.code.isTrashed)
MenuItem(
label: 'QR',
label: context.l10n.qr,
icon: Icons.qr_code_2_outlined,
onSelected: () => _onShowQrPressed(null),
),
@@ -307,7 +311,7 @@ class _CodeWidgetState extends State<CodeWidget> {
const MenuDivider(),
MenuItem(
label: widget.code.isTrashed ? l10n.delete : l10n.trash,
value: "Delete",
value: l10n.delete,
icon: widget.code.isTrashed
? Icons.delete_forever
: Icons.delete,
@@ -403,54 +407,64 @@ class _CodeWidgetState extends State<CodeWidget> {
);
}
Widget _getTopRow() {
bool isCompactMode = widget.isCompactMode;
return Padding(
padding: const EdgeInsets.only(left: 16, right: 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
safeDecode(widget.code.issuer).trim(),
style: isCompactMode
? Theme.of(context).textTheme.bodyMedium
: Theme.of(context).textTheme.titleLarge,
),
if (!isCompactMode) const SizedBox(height: 2),
Text(
safeDecode(widget.code.account).trim(),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontSize: isCompactMode ? 12 : 12,
color: Colors.grey,
),
),
],
Widget _getTopRow({required bool isSelected}) {
final colorScheme = getEnteColorScheme(context);
bool isCompactMode = widget.isCompactMode;
return Padding(
padding: const EdgeInsets.only(left: 16, right: 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (isSelected)
Padding(
padding: const EdgeInsets.only(right: 8.0),
child: Icon(
Icons.check_circle,
color: colorScheme.primary400,
size: isCompactMode ? 20 : 24,
),
),
const SizedBox(width: 8),
Row(
mainAxisAlignment: MainAxisAlignment.end,
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
(widget.code.hasSynced != null && widget.code.hasSynced!) ||
!hasConfiguredAccount
? const SizedBox.shrink()
: const Icon(
Icons.sync_disabled,
size: 20,
color: Colors.amber,
Text(
safeDecode(widget.code.issuer).trim(),
style: isCompactMode
? Theme.of(context).textTheme.bodyMedium
: Theme.of(context).textTheme.titleLarge,
),
if (!isCompactMode) const SizedBox(height: 2),
Text(
safeDecode(widget.code.account).trim(),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontSize: isCompactMode ? 12 : 12,
color: Colors.grey,
),
const SizedBox(width: 12),
_shouldShowLargeIcon ? const SizedBox.shrink() : _getIcon(),
),
],
),
],
),
);
}
),
const SizedBox(width: 8),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
(widget.code.hasSynced != null && widget.code.hasSynced!) ||
!hasConfiguredAccount
? const SizedBox.shrink()
: const Icon(
Icons.sync_disabled,
size: 20,
color: Colors.amber,
),
const SizedBox(width: 12),
_shouldShowLargeIcon ? const SizedBox.shrink() : _getIcon(),
],
),
],
),
);
}
Widget _getIcon() {
final String iconData;
@@ -478,7 +492,7 @@ class _CodeWidgetState extends State<CodeWidget> {
_getCurrentOTP(),
confirmationMessage: context.l10n.copiedToClipboard,
);
_udateCodeMetadata().ignore();
_updateCodeMetadata().ignore();
}
void _copyNextToClipboard() {
@@ -486,10 +500,10 @@ class _CodeWidgetState extends State<CodeWidget> {
_getNextTotp(),
confirmationMessage: context.l10n.copiedNextToClipboard,
);
_udateCodeMetadata().ignore();
_updateCodeMetadata().ignore();
}
Future<void> _udateCodeMetadata() async {
Future<void> _updateCodeMetadata() async {
if (widget.sortKey == null) return;
Future.delayed(const Duration(milliseconds: 100), () {
if (mounted) {
@@ -502,7 +516,7 @@ class _CodeWidgetState extends State<CodeWidget> {
lastUsedAt: DateTime.now().microsecondsSinceEpoch,
),
);
unawaited(CodeStore.instance.addCode(code));
unawaited(CodeStore.instance.updateCode(widget.code, code));
}
}
});
@@ -568,7 +582,7 @@ class _CodeWidgetState extends State<CodeWidget> {
),
);
if (code != null) {
await CodeStore.instance.addCode(code);
await CodeStore.instance.updateCode(widget.code, code);
}
}
@@ -615,7 +629,7 @@ class _CodeWidgetState extends State<CodeWidget> {
display: display.copyWith(pinned: !currentlyPinned),
);
unawaited(
CodeStore.instance.addCode(code).then(
CodeStore.instance.updateCode(widget.code,code).then(
(value) => showToast(
context,
!currentlyPinned
@@ -694,7 +708,7 @@ class _CodeWidgetState extends State<CodeWidget> {
final Code code = widget.code.copyWith(
display: display.copyWith(trashed: true),
);
await CodeStore.instance.addCode(code);
await CodeStore.instance.updateCode(widget.code, code);
} catch (e) {
logger.severe('Failed to trash code: ${e.toString()}');
showGenericErrorDialog(context: context, error: e).ignore();
@@ -718,7 +732,7 @@ class _CodeWidgetState extends State<CodeWidget> {
final Code code = widget.code.copyWith(
display: display.copyWith(trashed: false),
);
await CodeStore.instance.addCode(code);
await CodeStore.instance.updateCode(widget.code, code);
} catch (e) {
logger.severe('Failed to restore code: ${e.toString()}');
if (mounted) {

View File

@@ -0,0 +1,243 @@
import 'dart:async';
import 'package:ente_auth/l10n/l10n.dart';
import 'package:ente_auth/models/code.dart';
import 'package:ente_auth/store/code_display_store.dart';
import 'package:ente_auth/store/code_store.dart';
import 'package:ente_auth/theme/ente_theme.dart';
import 'package:ente_auth/ui/components/buttons/button_widget.dart';
import 'package:ente_auth/ui/components/models/button_type.dart';
import 'package:ente_auth/ui/utils/icon_utils.dart';
import 'package:flutter/material.dart';
class AddTagSheet extends StatefulWidget {
final List<Code> selectedCodes;
const AddTagSheet({
super.key,
required this.selectedCodes,
});
@override
State<AddTagSheet> createState() => _AddTagSheetState();
}
class _AddTagSheetState extends State<AddTagSheet> {
List<String> _allTags = [];
final Set<String> _selectedTagsInSheet = {};
bool _isLoading = true;
@override
void initState() {
super.initState();
_loadInitialState();
}
Future<void> _loadInitialState() async {
final allTagsFromServer = await CodeDisplayStore.instance.getAllTags();
final initialTagsForSelection = <String>{};
for (final code in widget.selectedCodes) {
initialTagsForSelection.addAll(code.display.tags);
}
if (mounted) {
setState(() {
_allTags = allTagsFromServer;
_selectedTagsInSheet.addAll(initialTagsForSelection);
_isLoading = false;
});
}
}
Future<void> _onDonePressed() async {
final List<Future> updateFutures = [];
for (final code in widget.selectedCodes) {
final updatedCode = code.copyWith(
display: code.display.copyWith(tags: _selectedTagsInSheet.toList()),
);
updateFutures.add(CodeStore.instance.updateCode(code, updatedCode));
}
await Future.wait(updateFutures);
if (mounted) {
Navigator.of(context).pop();
}
}
Future<void> _showCreateTagDialog() async {
final textController = TextEditingController();
final newTag = await showDialog<String>(
context: context,
builder: (context) {
return StatefulBuilder(
builder: (context, setState) {
return AlertDialog(
title: Text(context.l10n.createNewTag),
content: TextField(
controller: textController,
autofocus: true,
onChanged: (_) => setState(() {}),
),
actions: [
Row(
children: [
Expanded(
child: ButtonWidget(
buttonType: ButtonType.secondary,
labelText: context.l10n.cancel,
onTap: () async => Navigator.of(context).pop(),
),
),
const SizedBox(width: 8),
Expanded(
child: ButtonWidget(
buttonType: ButtonType.primary,
labelText: context.l10n.create,
isDisabled: textController.text.trim().isEmpty,
onTap: () async => Navigator.of(context).pop(textController.text.trim()),
),
),
],
),
],
);
},
);
},
);
if (newTag != null && newTag.isNotEmpty) {
setState(() {
if (!_allTags.contains(newTag)) {
_allTags.add(newTag);
_allTags.sort();
}
_selectedTagsInSheet.add(newTag);
});
}
}
@override
Widget build(BuildContext context) {
final colorScheme = getEnteColorScheme(context);
final textTheme = getEnteTextTheme(context);
final bottomPadding = MediaQuery.of(context).padding.bottom;
return Padding(
padding: EdgeInsets.fromLTRB(16, 16, 16, 16 + bottomPadding),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'${widget.selectedCodes.length} ${context.l10n.selected}',
style: textTheme.large,
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.of(context).pop(),
),
],
),
const SizedBox(height: 16),
SizedBox(
height: 80,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: widget.selectedCodes.length,
separatorBuilder: (_, __) => const SizedBox(width: 16),
itemBuilder: (context, index) {
final code = widget.selectedCodes[index];
final iconData =
code.display.isCustomIcon ? code.display.iconID : code.issuer;
return SizedBox(
width: 60,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconUtils.instance.getIcon(context, iconData.trim(), width: 40),
const SizedBox(height: 8),
Text(
code.issuer,
overflow: TextOverflow.ellipsis,
style: textTheme.mini.copyWith(color: colorScheme.textMuted,),
),
],
),
);
},
),
),
const SizedBox(height: 24),
Text(context.l10n.tags, style: textTheme.body),
const SizedBox(height: 12),
Flexible(
child: SingleChildScrollView(
child: _isLoading
? const Center(child: CircularProgressIndicator())
: Wrap(
spacing: 8.0,
runSpacing: 4.0,
children: [
..._allTags.map((tag) {
final isSelected = _selectedTagsInSheet.contains(tag);
return ChoiceChip(
label: Text(tag),
selected: isSelected,
onSelected: (selected) {
setState(() {
if (selected) {
_selectedTagsInSheet.add(tag);
} else {
_selectedTagsInSheet.remove(tag);
}
});
},
selectedColor: colorScheme.primary400,
backgroundColor: colorScheme.fillFaint,
labelStyle: TextStyle(
color: isSelected ? Colors.white : colorScheme.textBase,
),
avatar: isSelected ? const Icon(Icons.check, color: Colors.white, size: 16) : null,
side: BorderSide.none,
shape: const StadiumBorder(),
);
}),
ActionChip(
avatar: const Icon(Icons.add, size: 18),
label: Text(context.l10n.addNew),
onPressed: _showCreateTagDialog,
side: BorderSide.none,
shape: const StadiumBorder(),
backgroundColor: colorScheme.fillFaint,
),
],
),
),
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: colorScheme.fillBase,
foregroundColor: colorScheme.backgroundBase,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
onPressed: _onDonePressed,
child: Text(context.l10n.done),
),
),
],
),
);
}
}

View File

@@ -14,6 +14,7 @@ import 'package:ente_auth/models/code.dart';
import 'package:ente_auth/onboarding/model/tag_enums.dart';
import 'package:ente_auth/onboarding/view/common/tag_chip.dart';
import 'package:ente_auth/onboarding/view/setup_enter_secret_key_page.dart';
import 'package:ente_auth/onboarding/view/view_qr_page.dart';
import 'package:ente_auth/services/preference_service.dart';
import 'package:ente_auth/store/code_display_store.dart';
import 'package:ente_auth/store/code_store.dart';
@@ -26,17 +27,22 @@ import 'package:ente_auth/ui/common/loading_widget.dart';
import 'package:ente_auth/ui/components/buttons/button_widget.dart';
import 'package:ente_auth/ui/components/dialog_widget.dart';
import 'package:ente_auth/ui/components/models/button_type.dart';
import 'package:ente_auth/ui/home/add_tag_sheet.dart';
import 'package:ente_auth/ui/home/coach_mark_widget.dart';
import 'package:ente_auth/ui/home/home_empty_state.dart';
import 'package:ente_auth/ui/home/speed_dial_label_widget.dart';
import 'package:ente_auth/ui/reorder_codes_page.dart';
import 'package:ente_auth/ui/scanner_page.dart';
import 'package:ente_auth/ui/settings_page.dart';
import 'package:ente_auth/ui/share/code_share.dart';
import 'package:ente_auth/ui/sort_option_menu.dart';
import 'package:ente_auth/ui/utils/icon_utils.dart';
import 'package:ente_auth/utils/dialog_util.dart';
import 'package:ente_auth/utils/platform_util.dart';
import 'package:ente_auth/utils/toast_util.dart';
import 'package:ente_auth/utils/totp_util.dart';
import 'package:ente_events/event_bus.dart';
import 'package:ente_lock_screen/local_authentication_service.dart';
import 'package:ente_lock_screen/lock_screen_settings.dart';
import 'package:ente_lock_screen/ui/app_lock.dart';
import 'package:ente_qr/ente_qr.dart';
@@ -58,6 +64,7 @@ class HomePage extends BaseHomePage {
}
class _HomePageState extends State<HomePage> {
final _codeDisplayStore = CodeDisplayStore.instance;
late final _settingsPage = SettingsPage(
emailNotifier: UserService.instance.emailValueNotifier,
scaffoldKey: scaffoldKey,
@@ -94,6 +101,7 @@ class _HomePageState extends State<HomePage> {
@override
void initState() {
super.initState();
_codeSortKey = PreferenceService.instance.codeSortKey();
_textController.addListener(_applyFilteringAndRefresh);
_loadCodes();
@@ -119,6 +127,631 @@ class _HomePageState extends State<HomePage> {
ServicesBinding.instance.keyboard.addHandler(_handleKeyEvent);
}
void _onAddTagPressed() {
final selectedIds = _codeDisplayStore.selectedCodeIds.value;
final selectedCodes = _allCodes?.where((c) => selectedIds.contains(c.secret)).toList() ?? [];
if (selectedCodes.isEmpty) return;
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (_) {
return AddTagSheet(selectedCodes: selectedCodes);
},
).then((_) {
_codeDisplayStore.clearSelection();
});
}
Future<void> _onRestoreSelectedPressed() async {
final selectedIds = _codeDisplayStore.selectedCodeIds.value;
if (selectedIds.isEmpty) return;
FocusScope.of(context).requestFocus();
try {
final codesToRestore = _allCodes?.where((c) => selectedIds.contains(c.secret)).toList() ?? [];
for (final code in codesToRestore) {
final updatedCode = code.copyWith(display: code.display.copyWith(trashed: false));
unawaited(CodeStore.instance.updateCode(code, updatedCode));
}
} catch (e) {
if (mounted) {
showGenericErrorDialog(context: context, error: e).ignore();
}
} finally {
_codeDisplayStore.clearSelection();
}
}
Future<void> _onDeleteForeverPressed() async {
final l10n = context.l10n;
final selectedIds = _codeDisplayStore.selectedCodeIds.value;
if (selectedIds.isEmpty) return;
bool isAuthSuccessful =
await LocalAuthenticationService.instance.requestLocalAuthentication(
context,
context.l10n.deleteCodeAuthMessage,
);
if (!isAuthSuccessful) return;
FocusScope.of(context).requestFocus();
await showChoiceActionSheet(
context,
title: l10n.deleteCodeTitle,
body: l10n.deleteCodeMessage,
firstButtonLabel: l10n.delete,
isCritical: true,
firstButtonOnTap: () async {
try {
final codesToDelete = _allCodes?.where((c) => selectedIds.contains(c.secret)).toList() ?? [];
for (final code in codesToDelete) {
await CodeStore.instance.removeCode(code);
}
}
catch (e) {
if (mounted) {
showGenericErrorDialog(context: context, error: e).ignore();
}
}
finally {
_codeDisplayStore.clearSelection();
}
},
);
}
Widget _buildTrashSelectActions() {
return Container(
decoration: BoxDecoration(
color: Theme.of(context).brightness == Brightness.light
? const Color(0xFFF7F7F7)
: const Color(0xFF1E1E1E),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
_buildClearActionButton(Icons.restore,context.l10n.restore, _onRestoreSelectedPressed,),
_buildClearActionButton(Icons.delete_forever,context.l10n.delete, _onDeleteForeverPressed),
],
),
);
}
Future<void> _onPinSelectedPressed() async {
final selectedIds = _codeDisplayStore.selectedCodeIds.value;
if (selectedIds.isEmpty) return;
final codesToUpdate = _allCodes?.where((c) => selectedIds.contains(c.secret)).toList() ?? [];
if (codesToUpdate.isEmpty) return;
// Determine the state of the current selection (pinned/unpinned)
final bool allArePinned = codesToUpdate.every((code) => code.isPinned);
if (allArePinned) {
// if all are pinned, unpin all
for (final code in codesToUpdate) {
final updatedCode = code.copyWith(display: code.display.copyWith(pinned: false));
unawaited(CodeStore.instance.updateCode(code, updatedCode));
}
if (codesToUpdate.length == 1) {
showToast(context, context.l10n.unpinnedCodeMessage(codesToUpdate.first.issuer));
} else {
showToast(context, 'Unpinned ${codesToUpdate.length} item(s)');
}
} else {
int pinnedCount = 0;
for (final code in codesToUpdate) {
if (!code.isPinned) { // Only pin the codes that are currently unpinned
final updatedCode = code.copyWith(display: code.display.copyWith(pinned: true));
unawaited(CodeStore.instance.updateCode(code, updatedCode));
pinnedCount++;
}
}
if (pinnedCount == 1) {
final pinnedCode = codesToUpdate.firstWhere((c) => !c.isPinned);
showToast(context, context.l10n.pinnedCodeMessage(pinnedCode.issuer));
} else if (pinnedCount > 0) {
showToast(context, 'Pinned $pinnedCount item(s)');
}
}
_codeDisplayStore.clearSelection();
}
Future<void> _onUnpinSelectedPressed() async {
final selectedIds = _codeDisplayStore.selectedCodeIds.value;
if (selectedIds.isEmpty) return;
final codesToUpdate = _allCodes?.where((c) => selectedIds.contains(c.secret)).toList() ?? [];
if (codesToUpdate.isEmpty) return;
int unpinnedCount = 0;
for (final code in codesToUpdate) {
if (code.isPinned) { // only unpin the codes that are currently pinned
final updatedCode = code.copyWith(display: code.display.copyWith(pinned: false));
unawaited(CodeStore.instance.updateCode(code, updatedCode));
unpinnedCount++;
}
}
if (unpinnedCount == 1) {
final unpinnedCode = codesToUpdate.firstWhere((c) => c.isPinned);
showToast(context, context.l10n.unpinnedCodeMessage(unpinnedCode.issuer));
} else if (unpinnedCount > 0) {
showToast(context, 'Unpinned $unpinnedCount item(s)');
}
_codeDisplayStore.clearSelection();
}
Future<void> _onTrashSelectedPressed() async {
final l10n = context.l10n;
final selectedIds = _codeDisplayStore.selectedCodeIds.value;
if (selectedIds.isEmpty) return;
bool isAuthSuccessful =
await LocalAuthenticationService.instance.requestLocalAuthentication(
context,
context.l10n.deleteCodeAuthMessage,
);
if (!isAuthSuccessful) return;
FocusScope.of(context).requestFocus();
await showChoiceActionSheet(
context,
title: l10n.trashCode,
body: ((){
if (selectedIds.length == 1){
final code = _allCodes!.firstWhere((c) => c.secret == selectedIds.first);
final issuerAccount = code.account.isNotEmpty ? '${code.issuer} (${code.account})' : code.issuer;
return l10n.trashCodeMessage(issuerAccount);
}
else{
return l10n.moveMultipleToTrashMessage(selectedIds.length);
}
})(),
firstButtonLabel: l10n.trash,
isCritical: true,
firstButtonOnTap: () async {
try {
final codesToTrash = _allCodes?.where((c) => selectedIds.contains(c.secret)).toList() ?? [];
for (final code in codesToTrash) {
final updatedCode = code.copyWith(
display: code.display.copyWith(trashed: true),
);
unawaited(CodeStore.instance.updateCode(code, updatedCode));
}
} catch (e) {
_logger.severe('Failed to trash code(s): ${e.toString()}');
if (mounted) {
showGenericErrorDialog(context: context, error: e).ignore();
}
} finally {
_codeDisplayStore.clearSelection();
}
},
);
}
Future<void> _onEditPressed(Code code) async {
bool isAuthSuccessful = await LocalAuthenticationService.instance
.requestLocalAuthentication(context, context.l10n.editCodeAuthMessage);
await PlatformUtil.refocusWindows();
if (!isAuthSuccessful) return;
_codeDisplayStore.clearSelection();
final Code? updatedCode = await Navigator.of(context).push(
MaterialPageRoute(
builder: (BuildContext context) {
return SetupEnterSecretKeyPage(code: code);
},
),
);
if (updatedCode != null){
await CodeStore.instance.updateCode(code, updatedCode);
}
}
Future<void> _onSharePressed(Code code) async {
bool isAuthSuccessful = await LocalAuthenticationService.instance
.requestLocalAuthentication(context, context.l10n.authenticateGeneric);
await PlatformUtil.refocusWindows();
if (!isAuthSuccessful) return;
_codeDisplayStore.clearSelection();
showShareDialog(context, code);
}
Future<void> _onShowQrPressed(Code code) async {
bool isAuthSuccessful = await LocalAuthenticationService.instance
.requestLocalAuthentication(context, context.l10n.showQRAuthMessage);
await PlatformUtil.refocusWindows();
if (!isAuthSuccessful) return;
_codeDisplayStore.clearSelection();
await Navigator.of(context).push(
MaterialPageRoute(
builder: (BuildContext context) {
return ViewQrPage(code: code);
},
),
);
}
Widget _buildClearActionButton(IconData icon, String label, VoidCallback onTap) {
final colorScheme = getEnteColorScheme(context);
final textTheme = getEnteTextTheme(context);
return Expanded(
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
highlightColor: colorScheme.textBase.withValues(alpha: 0.1),
splashColor: colorScheme.textBase.withValues(alpha: 0.1),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 12.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, color: colorScheme.textBase, size: 18), //bottom row icon props
const SizedBox(height: 8),
Text(label, style: textTheme.small.copyWith(color: colorScheme.textBase, fontSize: 11)),
],
),
),
),
);
}
Widget _buildSingleSelectActions(Code code) {
final colorScheme = getEnteColorScheme(context);
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
_buildActionButton(Icons.edit_outlined, context.l10n.edit, () => _onEditPressed(code)),
const SizedBox(width: 10),
_buildActionButton(Icons.share_outlined, context.l10n.share, () => _onSharePressed(code)),
const SizedBox(width: 10),
_buildActionButton(Icons.qr_code, context.l10n.qrCode, () => _onShowQrPressed(code)),
],
),
const SizedBox(height: 16),
Container(
decoration: BoxDecoration(
color: Theme.of(context).brightness == Brightness.dark
? colorScheme.backgroundElevated2
: const Color(0xFFF7F7F7),
//color of the bottom button row on single select
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
ValueListenableBuilder<Set<String>>(
valueListenable: _codeDisplayStore.selectedCodeIds,
builder: (context, selectedIds, child) {
if (selectedIds.isEmpty) return const Expanded(child: SizedBox.shrink());
final selectedCodes = _allCodes?.where((c) => selectedIds.contains(c.secret)).toList() ?? [];
if (selectedCodes.isEmpty) return const Expanded(child: SizedBox.shrink());
final bool allArePinned = selectedCodes.every((code) => code.isPinned);
return _buildClearActionButton(
allArePinned ? Icons.push_pin : Icons.push_pin_outlined,
allArePinned ? context.l10n.unpinText : context.l10n.pinText,
_onPinSelectedPressed,
);
},
),
_buildClearActionButton(Icons.label_outline, context.l10n.addTag, _onAddTagPressed),
_buildClearActionButton(Icons.delete_outline, context.l10n.trash, _onTrashSelectedPressed),
],
),
),
],
);
}
Widget _buildMultiSelectActions(Set<String> selectedIds) {
final colorScheme = getEnteColorScheme(context);
return Container(
decoration: BoxDecoration(
color: Theme.of(context).brightness == Brightness.dark
? colorScheme.backgroundElevated2
: const Color(0xFFF7F7F7),
borderRadius: BorderRadius.circular(12),
),
child: ValueListenableBuilder<Set<String>>(
valueListenable: _codeDisplayStore.selectedCodeIds,
builder: (context, selectedIds, child) {
if (selectedIds.isEmpty) return const SizedBox.shrink();
final selectedCodes = _allCodes?.where((c) => selectedIds.contains(c.secret)).toList() ?? [];
if (selectedCodes.isEmpty) return const SizedBox.shrink();
final bool allArePinned = selectedCodes.every((code) => code.isPinned);
final bool allAreUnpinned = selectedCodes.every((code) => !code.isPinned);
final bool isMixed = !allArePinned && !allAreUnpinned;
if (isMixed) {
//mixed state: when selection contains both pinned and unpinned codes
return Row(
children: [
_buildClearActionButton(
Icons.push_pin_outlined,
context.l10n.pinText,
_onPinSelectedPressed,
),
_buildClearActionButton(
Icons.push_pin,
context.l10n.unpinText,
_onUnpinSelectedPressed,
),
_buildClearActionButton(
Icons.label_outline,
context.l10n.addTag,
_onAddTagPressed,
),
_buildClearActionButton(
Icons.delete_outline,
context.l10n.trash,
_onTrashSelectedPressed,
),
],
);
} else {
//when selection contains either only pinned OR only unpinned codes
return Row(
children: [
_buildClearActionButton(
allArePinned ? Icons.push_pin : Icons.push_pin_outlined,
allArePinned ? context.l10n.unpinText : context.l10n.pinText,
_onPinSelectedPressed,
),
_buildClearActionButton(
Icons.label_outline,
context.l10n.addTag,
_onAddTagPressed,
),
_buildClearActionButton(
Icons.delete_outline,
context.l10n.trash,
_onTrashSelectedPressed,
),
],
);
}
},
),
);
}
Widget _buildActionButton(IconData icon, String label, VoidCallback onTap) {
final colorScheme = getEnteColorScheme(context);
final textTheme = getEnteTextTheme(context);
return Expanded(
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).brightness == Brightness.dark
? colorScheme.backgroundElevated2
: const Color(0xFFF7F7F7),
borderRadius: BorderRadius.circular(12),
),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
highlightColor: colorScheme.textBase.withValues(alpha: 0.7),
splashColor: colorScheme.textBase.withValues(alpha: 0.7),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 12.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, color: colorScheme.textBase, size: 18), //top row icon props
const SizedBox(height: 8),
Text(
label,
style: textTheme.small.copyWith(color: colorScheme.textBase, fontSize: 11),
textAlign: TextAlign.center,
),
],
),
),
),
),
);
}
Widget _buildActionButtons() {
if (_isTrashOpen) {
return _buildTrashSelectActions();
}
return ValueListenableBuilder<Set<String>>(
valueListenable: _codeDisplayStore.selectedCodeIds,
builder: (context, selectedIds, child) {
if (selectedIds.isEmpty) {
return const SizedBox.shrink();
}
if (selectedIds.length == 1) {
final selectedCode = _allCodes?.firstWhereOrNull(
(c) => c.secret == selectedIds.first,
);
if (selectedCode == null) return const SizedBox.shrink();
return _buildSingleSelectActions(selectedCode);
} else {
return _buildMultiSelectActions(selectedIds);
}
},
);
}
Widget _buildSelectionActionBar() {
final bottomPadding = MediaQuery.of(context).padding.bottom;
final colorScheme = getEnteColorScheme(context);
return ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.4,
),
child: Card(
margin: EdgeInsets.zero,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(16),
topRight: Radius.circular(16),
),
),
elevation: 4,
color: Theme.of(context).brightness == Brightness.dark
? colorScheme.fillFaint
: colorScheme.backgroundElevated2,
child: Padding(
padding: EdgeInsets.fromLTRB(16, 16, 16, 16 + bottomPadding),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
//Select all pill
Material(
shape: StadiumBorder(
side: BorderSide(color: colorScheme.strokeMuted, width: 0.5),
),
color: colorScheme.backgroundElevated2,
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () {
final allVisibleCodeIds =
_filteredCodes.map((c) => c.secret).toSet();
_codeDisplayStore.selectedCodeIds.value = allVisibleCodeIds;
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.check_circle_outline_outlined,
color: Colors.grey,
size: 15,
),
const SizedBox(width: 6),
Text(context.l10n.selectAll, style: const TextStyle(fontSize: 11)),
],
),
),
),
),
// Center code logo icon
Expanded(
child: ValueListenableBuilder<Set<String>>(
valueListenable: _codeDisplayStore.selectedCodeIds,
builder: (context, selectedIds, child) {
if (selectedIds.isEmpty) {
return const SizedBox.shrink();
}
final selectedCodes = _allCodes
?.where((c) => selectedIds.contains(c.secret))
.toList() ??
[];
final codesToShow = selectedCodes.take(3).toList();
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
...codesToShow.map((code) {
final iconData = code.display.isCustomIcon
? code.display.iconID
: code.issuer;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 4.0),
child: IconUtils.instance
.getIcon(context, iconData.trim(), width: 17),
);
}),
if (selectedIds.length > 3)
Padding(
padding: const EdgeInsets.only(left: 4.0),
child: Text(
'+${selectedIds.length - 3}',
style: const TextStyle(),
),
),
],
);
},
),
),
// N selected pill
ValueListenableBuilder<Set<String>>(
valueListenable: _codeDisplayStore.selectedCodeIds,
builder: (context, selectedIds, child) {
return Material(
shape: StadiumBorder(
side: BorderSide(color: colorScheme.strokeMuted, width: 0.5),
),
color: colorScheme.backgroundElevated2,
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () {
_codeDisplayStore.clearSelection();
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'${selectedIds.length} selected',
style: const TextStyle(fontSize: 11),
),
const SizedBox(width: 6),
const Icon(
Icons.close,
size: 15,
color: Colors.grey,
),
],
),
),
),
);
},
),
],
),
const SizedBox(height: 16),
_buildActionButtons(),
],
),
),
),
),
);
}
bool _handleKeyEvent(KeyEvent event) {
if (event is KeyDownEvent) {
_pressedKeys.add(event.logicalKey);
@@ -153,6 +786,7 @@ class _HomePageState extends State<HomePage> {
}
void _loadCodes() {
debugPrint("[HOME_DEBUG] _loadCodes triggered!");
CodeStore.instance.getAllCodes().then((codes) {
_allCodes = codes;
hasTrashedCodes = false;
@@ -419,120 +1053,135 @@ class _HomePageState extends State<HomePage> {
}
@override
Widget build(BuildContext context) {
LockScreenSettings.instance
.setLightMode(getEnteColorScheme(context).isLightTheme);
final l10n = context.l10n;
isCompactMode = PreferenceService.instance.isCompactMode();
Widget build(BuildContext context) {
LockScreenSettings.instance
.setLightMode(getEnteColorScheme(context).isLightTheme);
final l10n = context.l10n;
isCompactMode = PreferenceService.instance.isCompactMode();
return PopScope(
onPopInvokedWithResult: (_, result) async {
if (_isSettingsOpen) {
scaffoldKey.currentState!.closeDrawer();
return;
} else if (!Platform.isAndroid) {
Navigator.of(context).pop();
return;
}
await MoveToBackground.moveTaskToBack();
},
canPop: false,
child: Scaffold(
key: scaffoldKey,
drawerEnableOpenDragGesture: !Platform.isAndroid,
drawer: Drawer(
width: 428,
child: _settingsPage,
),
onDrawerChanged: (isOpened) => _isSettingsOpen = isOpened,
body: SafeArea(
return ValueListenableBuilder<bool>(
valueListenable: _codeDisplayStore.isSelectionModeActive,
builder: (context, isSelecting, child) {
return PopScope(
canPop: false,
onPopInvokedWithResult: (_, result) async {
if (isSelecting) {
_codeDisplayStore.clearSelection();
return;
}
if (_isSettingsOpen) {
scaffoldKey.currentState!.closeDrawer();
return;
} else if (!Platform.isAndroid) {
Navigator.of(context).pop();
return;
}
await MoveToBackground.moveTaskToBack();
},
child: Scaffold(
key: scaffoldKey,
drawerEnableOpenDragGesture: !Platform.isAndroid,
drawer: Drawer(
width: 428,
child: _settingsPage,
),
onDrawerChanged: (isOpened) => _isSettingsOpen = isOpened,
body: SafeArea(
bottom: false,
child: Builder(
builder: (context) {
return _getBody();
},
),
),
resizeToAvoidBottomInset: false,
appBar: AppBar(
title: !_showSearchBox
? const Text('Ente Auth', style: brandStyleMedium)
: TextField(
autocorrect: false,
enableSuggestions: false,
autofocus: _autoFocusSearch,
controller: _textController,
onChanged: (val) {
_searchText = val;
_applyFilteringAndRefresh();
},
decoration: InputDecoration(
hintText: l10n.searchHint,
border: InputBorder.none,
focusedBorder: InputBorder.none,
),
bottomNavigationBar: isSelecting ? _buildSelectionActionBar() : null,
resizeToAvoidBottomInset: false,
appBar: AppBar(
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
surfaceTintColor: Colors.transparent,
title: !_showSearchBox
? const Text('Ente Auth', style: brandStyleMedium)
: TextField(
autocorrect: false,
enableSuggestions: false,
autofocus: _autoFocusSearch,
controller: _textController,
onChanged: (val) {
_searchText = val;
_applyFilteringAndRefresh();
},
decoration: InputDecoration(
hintText: l10n.searchHint,
border: InputBorder.none,
focusedBorder: InputBorder.none,
),
focusNode: searchBoxFocusNode,
),
focusNode: searchBoxFocusNode,
),
centerTitle: PlatformUtil.isDesktop() ? false : true,
actions: <Widget>[
Padding(
padding: const EdgeInsets.all(8.0),
child: SortCodeMenuWidget(
currentKey: PreferenceService.instance.codeSortKey(),
onSelected: (newOrder) async {
await PreferenceService.instance.setCodeSortKey(newOrder);
if (newOrder == CodeSortKey.manual &&
newOrder == _codeSortKey) {
await navigateToReorderPage(_allCodes!);
}
setState(() {
_codeSortKey = newOrder;
});
if (mounted) {
_applyFilteringAndRefresh();
}
},
),
),
if (PlatformUtil.isDesktop())
IconButton(
icon: const Icon(Icons.lock),
tooltip: l10n.appLock,
centerTitle: PlatformUtil.isDesktop() ? false : true,
actions: <Widget>[
Padding(
padding: const EdgeInsets.all(8.0),
onPressed: () async {
await navigateToLockScreen();
child: SortCodeMenuWidget(
currentKey: PreferenceService.instance.codeSortKey(),
onSelected: (newOrder) async {
await PreferenceService.instance.setCodeSortKey(newOrder);
if (newOrder == CodeSortKey.manual &&
newOrder == _codeSortKey) {
await navigateToReorderPage(_allCodes!);
}
setState(() {
_codeSortKey = newOrder;
});
if (mounted) {
_applyFilteringAndRefresh();
}
},
),
),
if (PlatformUtil.isDesktop())
IconButton(
icon: const Icon(Icons.lock),
tooltip: l10n.appLock,
padding: const EdgeInsets.all(8.0),
onPressed: () async {
await navigateToLockScreen();
},
),
IconButton(
icon: _showSearchBox
? const Icon(Icons.clear)
: const Icon(Icons.search),
tooltip: l10n.search,
padding: const EdgeInsets.all(8.0),
onPressed: () {
setState(() {
_showSearchBox = !_showSearchBox;
if (!_showSearchBox) {
_textController.clear();
_searchText = "";
} else {
_searchText = _textController.text;
searchBoxFocusNode.requestFocus();
}
_applyFilteringAndRefresh();
});
},
),
IconButton(
icon: _showSearchBox
? const Icon(Icons.clear)
: const Icon(Icons.search),
tooltip: l10n.search,
padding: const EdgeInsets.all(8.0),
onPressed: () {
setState(() {
_showSearchBox = !_showSearchBox;
if (!_showSearchBox) {
_textController.clear();
_searchText = "";
} else {
_searchText = _textController.text;
searchBoxFocusNode.requestFocus();
}
_applyFilteringAndRefresh();
});
},
),
],
],
),
floatingActionButton: isSelecting
? null
: (!_hasLoaded ||
(_allCodes?.isEmpty ?? true) ||
!PreferenceService.instance.hasShownCoachMark()
? null
: _getFab()),
),
floatingActionButton: !_hasLoaded ||
(_allCodes?.isEmpty ?? true) ||
!PreferenceService.instance.hasShownCoachMark()
? null
: _getFab(),
),
);
}
);
},
);
}
Widget _getBody() {
final l10n = context.l10n;
@@ -570,6 +1219,7 @@ class _HomePageState extends State<HomePage> {
? TagChipState.selected
: TagChipState.unselected,
onTap: () {
_codeDisplayStore.clearSelection();
selectedTag = "";
_isTrashOpen = false;
@@ -586,6 +1236,7 @@ class _HomePageState extends State<HomePage> {
? TagChipState.selected
: TagChipState.unselected,
onTap: () {
_codeDisplayStore.clearSelection();
selectedTag = "";
_isTrashOpen = !_isTrashOpen;
setState(() {});
@@ -603,6 +1254,7 @@ class _HomePageState extends State<HomePage> {
? TagChipState.selected
: TagChipState.unselected,
onTap: () {
_codeDisplayStore.clearSelection();
_isTrashOpen = false;
if (selectedTag == tags[customTagIndex]) {
selectedTag = "";
@@ -817,4 +1469,4 @@ class _HomePageState extends State<HomePage> {
],
);
}
}
}

View File

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

View File

@@ -5,7 +5,6 @@ import 'dart:io';
import 'package:ente_auth/l10n/l10n.dart';
import 'package:ente_auth/models/code.dart';
import 'package:ente_auth/models/export/ente.dart';
import 'package:ente_auth/services/authenticator_service.dart';
import 'package:ente_auth/store/code_store.dart';
import 'package:ente_auth/ui/components/buttons/button_widget.dart';
import 'package:ente_auth/ui/components/dialog_widget.dart';
@@ -46,7 +45,7 @@ Future<void> showEncryptedImportInstruction(BuildContext context) async {
if (result?.action != null && result!.action != ButtonAction.cancel) {
if (result.action == ButtonAction.first) {
await _pickEnteJsonFile(context);
} else {}
}
}
}
@@ -58,6 +57,9 @@ Future<void> _decryptExportData(
final l10n = context.l10n;
bool isPasswordIncorrect = false;
int? importedCodeCount;
bool importHasRun = false;
await showTextInputDialog(
context,
title: l10n.passwordForDecryptingExport,
@@ -67,6 +69,11 @@ Future<void> _decryptExportData(
alwaysShowSuccessState: false,
showOnlyLoadingState: true,
onSubmit: (String password) async {
if (importHasRun) {
return;
}
importHasRun = true;
if (password.isEmpty) {
showToast(context, l10n.passwordEmptyError);
Future.delayed(const Duration(seconds: 0), () {
@@ -78,6 +85,7 @@ Future<void> _decryptExportData(
final progressDialog = createProgressDialog(context, l10n.pleaseWait);
try {
await progressDialog.show();
final derivedKey = await CryptoUtil.deriveKey(
utf8.encode(password),
CryptoUtil.base642bin(enteAuthExport.kdfParams.salt),
@@ -85,7 +93,6 @@ Future<void> _decryptExportData(
enteAuthExport.kdfParams.opsLimit,
);
Uint8List? decryptedContent;
// Encrypt the key with this derived key
try {
decryptedContent = await CryptoUtil.decryptData(
CryptoUtil.base642bin(enteAuthExport.encryptedData),
@@ -99,27 +106,62 @@ Future<void> _decryptExportData(
}
if (isPasswordIncorrect) {
await progressDialog.hide();
Future.delayed(const Duration(seconds: 0), () {
_decryptExportData(context, enteAuthExport, password: password);
});
return;
}
String content = utf8.decode(decryptedContent!);
List<String> splitCodes = content.split("\n");
final parsedCodes = [];
for (final code in splitCodes) {
final List<Code> parsedCodes = [];
for (final line in splitCodes) {
if (line.trim().isEmpty) continue;
try {
parsedCodes.add(Code.fromOTPAuthUrl(code));
String otpUrl = jsonDecode(line);
parsedCodes.add(Code.fromOTPAuthUrl(otpUrl));
} catch (e) {
Logger('EncryptedText').severe("Could not parse code", e);
}
}
for (final code in parsedCodes) {
await CodeStore.instance.addCode(code, shouldSync: false);
final List<Code> codesInApp = await CodeStore.instance.getAllCodes();
final Map<String, Code> appCodesBySecret = { for (var code in codesInApp) code.secret: code };
final List<Code> codesToImportAsNew = [];
final List<Code> codesToUpdate = [];
final Set<String> processedSecrets = {};
for (final codeFromFile in parsedCodes) {
if (processedSecrets.contains(codeFromFile.secret)) {
continue;
}
processedSecrets.add(codeFromFile.secret);
if (appCodesBySecret.containsKey(codeFromFile.secret)) {
final originalCodeInApp = appCodesBySecret[codeFromFile.secret]!;
final updatedCode = codeFromFile.copyWith();
updatedCode.generatedID = originalCodeInApp.generatedID;
codesToUpdate.add(updatedCode);
} else {
codesToImportAsNew.add(codeFromFile);
}
}
unawaited(AuthenticatorService.instance.onlineSync());
importedCodeCount = parsedCodes.length;
if (codesToUpdate.isNotEmpty) {
for (final codeToUpdate in codesToUpdate) {
final originalCode = appCodesBySecret[codeToUpdate.secret]!;
await CodeStore.instance.updateCode(originalCode, codeToUpdate, shouldSync: false);
}
}
if (codesToImportAsNew.isNotEmpty) {
for (final newCode in codesToImportAsNew) {
await CodeStore.instance.addCode(newCode, shouldSync: false);
}
}
importedCodeCount = codesToImportAsNew.length + codesToUpdate.length;
await progressDialog.hide();
} catch (e, s) {
await progressDialog.hide();
@@ -153,4 +195,4 @@ Future<void> _pickEnteJsonFile(BuildContext context) async {
context.l10n.importFailureDescNew,
);
}
}
}

View File

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

View File

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

View File

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

View File

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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

View File

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

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

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

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

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

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

@@ -350,51 +350,161 @@
"matrix": "Matrix",
"discord": "Discord",
"reddit": "Reddit",
"information": "Information",
"saveInformation": "Save information",
"informationDescription": "Save important information that can be shared and passed down to loved ones.",
"personalNote": "Personal note",
"personalNoteDescription": "Save important notes or thoughts",
"physicalRecords": "Physical records",
"physicalRecordsDescription": "Save the real-world locations of important items",
"accountCredentials": "Account credentials",
"accountCredentialsDescription": "Securely store login details for important accounts",
"emergencyContact": "Emergency contact",
"emergencyContactDescription": "Save details of people to contact in emergencies",
"noteName": "Title",
"noteNameHint": "Give your note a meaningful title",
"noteContent": "Content",
"noteContentHint": "Write down important thoughts, instructions, or memories you want to preserve",
"recordName": "Record name",
"recordNameHint": "Name of the real-world item",
"recordLocation": "Location",
"recordLocationHint": "Where can this item be found? (e.g., 'Safety deposit box at First Bank, Box #123')",
"recordNotes": "Notes",
"recordNotesHint": "Any additional details about accessing or understanding this record",
"credentialName": "Account name",
"credentialNameHint": "Name of the service or account",
"username": "Username",
"usernameHint": "Login username or email address",
"password": "Password",
"passwordHint": "Account password",
"credentialNotes": "Additional notes",
"credentialNotesHint": "Recovery methods, security questions, or other important details",
"contactName": "Contact name",
"contactNameHint": "Full name of the emergency contact",
"contactDetails": "Contact details",
"contactDetailsHint": "Phone number, email, or other contact information",
"contactNotes": "Message for contact",
"contactNotesHint": "Important information to share with this person when they are contacted",
"saveRecord": "Save",
"recordSavedSuccessfully": "Record saved successfully",
"failedToSaveRecord": "Failed to save record",
"pleaseEnterNoteName": "Please enter a title",
"pleaseEnterNoteContent": "Please enter content",
"pleaseEnterRecordName": "Please enter a record name",
"pleaseEnterLocation": "Please enter a location",
"pleaseEnterAccountName": "Please enter an account name",
"pleaseEnterUsername": "Please enter a username",
"pleaseEnterPassword": "Please enter a password",
"pleaseEnterContactName": "Please enter a contact name",
"pleaseEnterContactDetails": "Please enter contact details"
"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

@@ -1018,287 +1018,587 @@ abstract class AppLocalizations {
/// **'Reddit'**
String get reddit;
/// No description provided for @information.
/// No description provided for @allowDownloads.
///
/// In en, this message translates to:
/// **'Information'**
String get information;
/// **'Allow downloads'**
String get allowDownloads;
/// No description provided for @saveInformation.
/// No description provided for @sharedByYou.
///
/// In en, this message translates to:
/// **'Save information'**
String get saveInformation;
/// **'Shared by you'**
String get sharedByYou;
/// No description provided for @informationDescription.
/// No description provided for @sharedWithYou.
///
/// In en, this message translates to:
/// **'Save important information that can be shared and passed down to loved ones.'**
String get informationDescription;
/// **'Shared with you'**
String get sharedWithYou;
/// No description provided for @personalNote.
/// No description provided for @manageLink.
///
/// In en, this message translates to:
/// **'Personal note'**
String get personalNote;
/// **'Manage link'**
String get manageLink;
/// No description provided for @personalNoteDescription.
/// No description provided for @linkExpiry.
///
/// In en, this message translates to:
/// **'Save important notes or thoughts'**
String get personalNoteDescription;
/// **'Link expiry'**
String get linkExpiry;
/// No description provided for @physicalRecords.
/// No description provided for @linkNeverExpires.
///
/// In en, this message translates to:
/// **'Physical records'**
String get physicalRecords;
/// **'Never'**
String get linkNeverExpires;
/// No description provided for @physicalRecordsDescription.
/// No description provided for @linkExpired.
///
/// In en, this message translates to:
/// **'Save the real-world locations of important items'**
String get physicalRecordsDescription;
/// **'Expired'**
String get linkExpired;
/// No description provided for @accountCredentials.
/// No description provided for @linkEnabled.
///
/// In en, this message translates to:
/// **'Account credentials'**
String get accountCredentials;
/// **'Enabled'**
String get linkEnabled;
/// No description provided for @accountCredentialsDescription.
/// No description provided for @setAPassword.
///
/// In en, this message translates to:
/// **'Securely store login details for important accounts'**
String get accountCredentialsDescription;
/// **'Set a password'**
String get setAPassword;
/// No description provided for @emergencyContact.
/// No description provided for @lockButtonLabel.
///
/// In en, this message translates to:
/// **'Emergency contact'**
String get emergencyContact;
/// **'Lock'**
String get lockButtonLabel;
/// No description provided for @emergencyContactDescription.
/// No description provided for @enterPassword.
///
/// In en, this message translates to:
/// **'Save details of people to contact in emergencies'**
String get emergencyContactDescription;
/// **'Enter password'**
String get enterPassword;
/// No description provided for @noteName.
/// No description provided for @removeLink.
///
/// In en, this message translates to:
/// **'Title'**
String get noteName;
/// **'Remove link'**
String get removeLink;
/// No description provided for @noteNameHint.
/// No description provided for @sendLink.
///
/// In en, this message translates to:
/// **'Give your note a meaningful title'**
String get noteNameHint;
/// **'Send link'**
String get sendLink;
/// No description provided for @noteContent.
/// No description provided for @setPasswordTitle.
///
/// In en, this message translates to:
/// **'Content'**
String get noteContent;
/// **'Set password'**
String get setPasswordTitle;
/// No description provided for @noteContentHint.
/// No description provided for @resetPasswordTitle.
///
/// In en, this message translates to:
/// **'Write down important thoughts, instructions, or memories you want to preserve'**
String get noteContentHint;
/// **'Reset password'**
String get resetPasswordTitle;
/// No description provided for @recordName.
/// No description provided for @allowAddingFiles.
///
/// In en, this message translates to:
/// **'Record name'**
String get recordName;
/// **'Allow adding files'**
String get allowAddingFiles;
/// No description provided for @recordNameHint.
/// No description provided for @disableDownloadWarningTitle.
///
/// In en, this message translates to:
/// **'Name of the real-world item'**
String get recordNameHint;
/// **'Please note'**
String get disableDownloadWarningTitle;
/// No description provided for @recordLocation.
/// No description provided for @disableDownloadWarningBody.
///
/// In en, this message translates to:
/// **'Location'**
String get recordLocation;
/// **'Viewers can still take screenshots or save a copy of your files using external tools.'**
String get disableDownloadWarningBody;
/// No description provided for @recordLocationHint.
/// No description provided for @allowAddFilesDescription.
///
/// In en, this message translates to:
/// **'Where can this item be found? (e.g., \'Safety deposit box at First Bank, Box #123\')'**
String get recordLocationHint;
/// **'Allow people with the link to also add files to the shared collection.'**
String get allowAddFilesDescription;
/// No description provided for @recordNotes.
/// No description provided for @after1Hour.
///
/// In en, this message translates to:
/// **'Notes'**
String get recordNotes;
/// **'After 1 hour'**
String get after1Hour;
/// No description provided for @recordNotesHint.
/// No description provided for @after1Day.
///
/// In en, this message translates to:
/// **'Any additional details about accessing or understanding this record'**
String get recordNotesHint;
/// **'After 1 day'**
String get after1Day;
/// No description provided for @credentialName.
/// No description provided for @after1Week.
///
/// In en, this message translates to:
/// **'Account name'**
String get credentialName;
/// **'After 1 week'**
String get after1Week;
/// No description provided for @credentialNameHint.
/// No description provided for @after1Month.
///
/// In en, this message translates to:
/// **'Name of the service or account'**
String get credentialNameHint;
/// **'After 1 month'**
String get after1Month;
/// No description provided for @username.
/// No description provided for @after1Year.
///
/// In en, this message translates to:
/// **'Username'**
String get username;
/// **'After 1 year'**
String get after1Year;
/// No description provided for @usernameHint.
/// No description provided for @never.
///
/// In en, this message translates to:
/// **'Login username or email address'**
String get usernameHint;
/// **'Never'**
String get never;
/// No description provided for @password.
/// No description provided for @custom.
///
/// In en, this message translates to:
/// **'Password'**
String get password;
/// **'Custom'**
String get custom;
/// No description provided for @passwordHint.
/// No description provided for @selectTime.
///
/// In en, this message translates to:
/// **'Account password'**
String get passwordHint;
/// **'Select time'**
String get selectTime;
/// No description provided for @credentialNotes.
/// No description provided for @selectDate.
///
/// In en, this message translates to:
/// **'Additional notes'**
String get credentialNotes;
/// **'Select date'**
String get selectDate;
/// No description provided for @credentialNotesHint.
/// No description provided for @previous.
///
/// In en, this message translates to:
/// **'Recovery methods, security questions, or other important details'**
String get credentialNotesHint;
/// **'Previous'**
String get previous;
/// No description provided for @contactName.
/// No description provided for @done.
///
/// In en, this message translates to:
/// **'Contact name'**
String get contactName;
/// **'Done'**
String get done;
/// No description provided for @contactNameHint.
/// No description provided for @next.
///
/// In en, this message translates to:
/// **'Full name of the emergency contact'**
String get contactNameHint;
/// **'Next'**
String get next;
/// No description provided for @contactDetails.
/// No description provided for @noDeviceLimit.
///
/// In en, this message translates to:
/// **'Contact details'**
String get contactDetails;
/// **'None'**
String get noDeviceLimit;
/// No description provided for @contactDetailsHint.
/// No description provided for @linkDeviceLimit.
///
/// In en, this message translates to:
/// **'Phone number, email, or other contact information'**
String get contactDetailsHint;
/// **'Device limit'**
String get linkDeviceLimit;
/// No description provided for @contactNotes.
/// No description provided for @expiredLinkInfo.
///
/// In en, this message translates to:
/// **'Message for contact'**
String get contactNotes;
/// **'This link has expired. Please select a new expiry time or disable link expiry.'**
String get expiredLinkInfo;
/// No description provided for @contactNotesHint.
/// No description provided for @linkExpiresOn.
///
/// In en, this message translates to:
/// **'Important information to share with this person when they are contacted'**
String get contactNotesHint;
/// **'Link will expire on {expiryTime}'**
String linkExpiresOn(Object expiryTime);
/// No description provided for @saveRecord.
/// No description provided for @shareWithPeopleSectionTitle.
///
/// In en, this message translates to:
/// **'Save'**
String get saveRecord;
/// **'{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 @recordSavedSuccessfully.
/// No description provided for @linkHasExpired.
///
/// In en, this message translates to:
/// **'Record saved successfully'**
String get recordSavedSuccessfully;
/// **'Link has expired'**
String get linkHasExpired;
/// No description provided for @failedToSaveRecord.
/// No description provided for @publicLinkEnabled.
///
/// In en, this message translates to:
/// **'Failed to save record'**
String get failedToSaveRecord;
/// **'Public link enabled'**
String get publicLinkEnabled;
/// No description provided for @pleaseEnterNoteName.
/// No description provided for @shareALink.
///
/// In en, this message translates to:
/// **'Please enter a title'**
String get pleaseEnterNoteName;
/// **'Share a link'**
String get shareALink;
/// No description provided for @pleaseEnterNoteContent.
/// No description provided for @addViewer.
///
/// In en, this message translates to:
/// **'Please enter content'**
String get pleaseEnterNoteContent;
/// **'Add viewer'**
String get addViewer;
/// No description provided for @pleaseEnterRecordName.
/// No description provided for @addCollaborator.
///
/// In en, this message translates to:
/// **'Please enter a record name'**
String get pleaseEnterRecordName;
/// **'Add collaborator'**
String get addCollaborator;
/// No description provided for @pleaseEnterLocation.
/// No description provided for @addANewEmail.
///
/// In en, this message translates to:
/// **'Please enter a location'**
String get pleaseEnterLocation;
/// **'Add a new email'**
String get addANewEmail;
/// No description provided for @pleaseEnterAccountName.
/// No description provided for @orPickAnExistingOne.
///
/// In en, this message translates to:
/// **'Please enter an account name'**
String get pleaseEnterAccountName;
/// **'Or pick an existing one'**
String get orPickAnExistingOne;
/// No description provided for @pleaseEnterUsername.
/// No description provided for @sharedCollectionSectionDescription.
///
/// In en, this message translates to:
/// **'Please enter a username'**
String get pleaseEnterUsername;
/// **'Create shared and collaborative collections with other Ente users, including users on free plans.'**
String get sharedCollectionSectionDescription;
/// No description provided for @pleaseEnterPassword.
/// No description provided for @createPublicLink.
///
/// In en, this message translates to:
/// **'Please enter a password'**
String get pleaseEnterPassword;
/// **'Create public link'**
String get createPublicLink;
/// No description provided for @pleaseEnterContactName.
/// No description provided for @addParticipants.
///
/// In en, this message translates to:
/// **'Please enter a contact name'**
String get pleaseEnterContactName;
/// **'Add participants'**
String get addParticipants;
/// No description provided for @pleaseEnterContactDetails.
/// No description provided for @add.
///
/// In en, this message translates to:
/// **'Please enter contact details'**
String get pleaseEnterContactDetails;
/// **'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

@@ -536,153 +536,378 @@ class AppLocalizationsEn extends AppLocalizations {
String get reddit => 'Reddit';
@override
String get information => 'Information';
String get allowDownloads => 'Allow downloads';
@override
String get saveInformation => 'Save information';
String get sharedByYou => 'Shared by you';
@override
String get informationDescription =>
'Save important information that can be shared and passed down to loved ones.';
String get sharedWithYou => 'Shared with you';
@override
String get personalNote => 'Personal note';
String get manageLink => 'Manage link';
@override
String get personalNoteDescription => 'Save important notes or thoughts';
String get linkExpiry => 'Link expiry';
@override
String get physicalRecords => 'Physical records';
String get linkNeverExpires => 'Never';
@override
String get physicalRecordsDescription =>
'Save the real-world locations of important items';
String get linkExpired => 'Expired';
@override
String get accountCredentials => 'Account credentials';
String get linkEnabled => 'Enabled';
@override
String get accountCredentialsDescription =>
'Securely store login details for important accounts';
String get setAPassword => 'Set a password';
@override
String get emergencyContact => 'Emergency contact';
String get lockButtonLabel => 'Lock';
@override
String get emergencyContactDescription =>
'Save details of people to contact in emergencies';
String get enterPassword => 'Enter password';
@override
String get noteName => 'Title';
String get removeLink => 'Remove link';
@override
String get noteNameHint => 'Give your note a meaningful title';
String get sendLink => 'Send link';
@override
String get noteContent => 'Content';
String get setPasswordTitle => 'Set password';
@override
String get noteContentHint =>
'Write down important thoughts, instructions, or memories you want to preserve';
String get resetPasswordTitle => 'Reset password';
@override
String get recordName => 'Record name';
String get allowAddingFiles => 'Allow adding files';
@override
String get recordNameHint => 'Name of the real-world item';
String get disableDownloadWarningTitle => 'Please note';
@override
String get recordLocation => 'Location';
String get disableDownloadWarningBody =>
'Viewers can still take screenshots or save a copy of your files using external tools.';
@override
String get recordLocationHint =>
'Where can this item be found? (e.g., \'Safety deposit box at First Bank, Box #123\')';
String get allowAddFilesDescription =>
'Allow people with the link to also add files to the shared collection.';
@override
String get recordNotes => 'Notes';
String get after1Hour => 'After 1 hour';
@override
String get recordNotesHint =>
'Any additional details about accessing or understanding this record';
String get after1Day => 'After 1 day';
@override
String get credentialName => 'Account name';
String get after1Week => 'After 1 week';
@override
String get credentialNameHint => 'Name of the service or account';
String get after1Month => 'After 1 month';
@override
String get username => 'Username';
String get after1Year => 'After 1 year';
@override
String get usernameHint => 'Login username or email address';
String get never => 'Never';
@override
String get password => 'Password';
String get custom => 'Custom';
@override
String get passwordHint => 'Account password';
String get selectTime => 'Select time';
@override
String get credentialNotes => 'Additional notes';
String get selectDate => 'Select date';
@override
String get credentialNotesHint =>
'Recovery methods, security questions, or other important details';
String get previous => 'Previous';
@override
String get contactName => 'Contact name';
String get done => 'Done';
@override
String get contactNameHint => 'Full name of the emergency contact';
String get next => 'Next';
@override
String get contactDetails => 'Contact details';
String get noDeviceLimit => 'None';
@override
String get contactDetailsHint =>
'Phone number, email, or other contact information';
String get linkDeviceLimit => 'Device limit';
@override
String get contactNotes => 'Message for contact';
String get expiredLinkInfo =>
'This link has expired. Please select a new expiry time or disable link expiry.';
@override
String get contactNotesHint =>
'Important information to share with this person when they are contacted';
String linkExpiresOn(Object expiryTime) {
return 'Link will expire on $expiryTime';
}
@override
String get saveRecord => 'Save';
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 recordSavedSuccessfully => 'Record saved successfully';
String get linkHasExpired => 'Link has expired';
@override
String get failedToSaveRecord => 'Failed to save record';
String get publicLinkEnabled => 'Public link enabled';
@override
String get pleaseEnterNoteName => 'Please enter a title';
String get shareALink => 'Share a link';
@override
String get pleaseEnterNoteContent => 'Please enter content';
String get addViewer => 'Add viewer';
@override
String get pleaseEnterRecordName => 'Please enter a record name';
String get addCollaborator => 'Add collaborator';
@override
String get pleaseEnterLocation => 'Please enter a location';
String get addANewEmail => 'Add a new email';
@override
String get pleaseEnterAccountName => 'Please enter an account name';
String get orPickAnExistingOne => 'Or pick an existing one';
@override
String get pleaseEnterUsername => 'Please enter a username';
String get sharedCollectionSectionDescription =>
'Create shared and collaborative collections with other Ente users, including users on free plans.';
@override
String get pleaseEnterPassword => 'Please enter a password';
String get createPublicLink => 'Create public link';
@override
String get pleaseEnterContactName => 'Please enter a contact name';
String get addParticipants => 'Add participants';
@override
String get pleaseEnterContactDetails => 'Please enter contact details';
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,6 +4,7 @@ import 'dart:io';
import 'package:adaptive_theme/adaptive_theme.dart';
import 'package:ente_accounts/services/user_service.dart';
import 'package:ente_crypto_dart/ente_crypto_dart.dart';
import "package:ente_legacy/services/emergency_service.dart";
import 'package:ente_lock_screen/lock_screen_settings.dart';
import 'package:ente_lock_screen/ui/app_lock.dart';
import 'package:ente_lock_screen/ui/lock_screen.dart';
@@ -169,4 +170,8 @@ Future<void> _init(bool bool, {String? via}) async {
packageInfo,
);
await TrashService.instance.init(preferences);
await EmergencyContactService.instance.init(
UserService.instance,
Configuration.instance,
);
}

View File

@@ -1,54 +0,0 @@
enum FileType {
image,
video,
livePhoto,
other,
info, // New type for information files
}
int getInt(FileType fileType) {
switch (fileType) {
case FileType.image:
return 0;
case FileType.video:
return 1;
case FileType.livePhoto:
return 2;
case FileType.other:
return 3;
case FileType.info:
return 4;
}
}
FileType getFileType(int fileType) {
switch (fileType) {
case 0:
return FileType.image;
case 1:
return FileType.video;
case 2:
return FileType.livePhoto;
case 3:
return FileType.other;
case 4:
return FileType.info;
default:
return FileType.other;
}
}
String getHumanReadableString(FileType fileType) {
switch (fileType) {
case FileType.image:
return 'Images';
case FileType.video:
return 'Videos';
case FileType.livePhoto:
return 'Live Photos';
case FileType.other:
return 'Other Files';
case FileType.info:
return 'Information';
}
}

View File

@@ -1,244 +0,0 @@
import 'dart:convert';
// Enum for different information types
enum InfoType {
note,
physicalRecord,
accountCredential,
emergencyContact,
}
// Extension to convert enum to string and vice versa
extension InfoTypeExtension on InfoType {
String get value {
switch (this) {
case InfoType.note:
return 'note';
case InfoType.physicalRecord:
return 'physical-record';
case InfoType.accountCredential:
return 'account-credential';
case InfoType.emergencyContact:
return 'emergency-contact';
}
}
static InfoType fromString(String value) {
switch (value) {
case 'note':
return InfoType.note;
case 'physical-record':
return InfoType.physicalRecord;
case 'account-credential':
return InfoType.accountCredential;
case 'emergency-contact':
return InfoType.emergencyContact;
default:
throw ArgumentError('Unknown InfoType: $value');
}
}
}
// Base class for all information data
abstract class InfoData {
Map<String, dynamic> toJson();
static InfoData fromJson(InfoType type, Map<String, dynamic> json) {
switch (type) {
case InfoType.note:
return PersonalNoteData.fromJson(json);
case InfoType.physicalRecord:
return PhysicalRecordData.fromJson(json);
case InfoType.accountCredential:
return AccountCredentialData.fromJson(json);
case InfoType.emergencyContact:
return EmergencyContactData.fromJson(json);
}
}
}
// Personal Note Data Model
class PersonalNoteData extends InfoData {
final String title;
final String content;
PersonalNoteData({
required this.title,
required this.content,
});
factory PersonalNoteData.fromJson(Map<String, dynamic> json) {
return PersonalNoteData(
title: json['title'] ?? '',
content: json['content'] ?? '',
);
}
@override
Map<String, dynamic> toJson() {
return {
'title': title,
'content': content,
};
}
}
// Physical Record Data Model
class PhysicalRecordData extends InfoData {
final String name;
final String location;
final String? notes;
PhysicalRecordData({
required this.name,
required this.location,
this.notes,
});
factory PhysicalRecordData.fromJson(Map<String, dynamic> json) {
return PhysicalRecordData(
name: json['name'] ?? '',
location: json['location'] ?? '',
notes: json['notes'],
);
}
@override
Map<String, dynamic> toJson() {
return {
'name': name,
'location': location,
if (notes != null && notes!.isNotEmpty) 'notes': notes,
};
}
}
// Account Credential Data Model
class AccountCredentialData extends InfoData {
final String name;
final String username;
final String password;
final String? notes;
AccountCredentialData({
required this.name,
required this.username,
required this.password,
this.notes,
});
factory AccountCredentialData.fromJson(Map<String, dynamic> json) {
return AccountCredentialData(
name: json['name'] ?? '',
username: json['username'] ?? '',
password: json['password'] ?? '',
notes: json['notes'],
);
}
@override
Map<String, dynamic> toJson() {
return {
'name': name,
'username': username,
'password': password,
if (notes != null && notes!.isNotEmpty) 'notes': notes,
};
}
}
// Emergency Contact Data Model
class EmergencyContactData extends InfoData {
final String name;
final String contactDetails;
final String? notes;
EmergencyContactData({
required this.name,
required this.contactDetails,
this.notes,
});
factory EmergencyContactData.fromJson(Map<String, dynamic> json) {
return EmergencyContactData(
name: json['name'] ?? '',
contactDetails: json['contactDetails'] ?? '',
notes: json['notes'],
);
}
@override
Map<String, dynamic> toJson() {
return {
'name': name,
'contactDetails': contactDetails,
if (notes != null && notes!.isNotEmpty) 'notes': notes,
};
}
}
// Main Information Item wrapper
class InfoItem {
final InfoType type;
final InfoData data;
final DateTime createdAt;
final DateTime? updatedAt;
InfoItem({
required this.type,
required this.data,
required this.createdAt,
this.updatedAt,
});
factory InfoItem.fromJson(Map<String, dynamic> json) {
final type = InfoTypeExtension.fromString(json['type']);
final data = InfoData.fromJson(type, json['data']);
return InfoItem(
type: type,
data: data,
createdAt: DateTime.parse(json['createdAt']),
updatedAt:
json['updatedAt'] != null ? DateTime.parse(json['updatedAt']) : null,
);
}
Map<String, dynamic> toJson() {
return {
'type': type.value,
'data': data.toJson(),
'createdAt': createdAt.toIso8601String(),
if (updatedAt != null) 'updatedAt': updatedAt!.toIso8601String(),
};
}
String toJsonString() => jsonEncode(toJson());
static InfoItem fromJsonString(String jsonString) {
return InfoItem.fromJson(jsonDecode(jsonString));
}
// Create a copy with updated data
InfoItem copyWith({
InfoType? type,
InfoData? data,
DateTime? createdAt,
DateTime? updatedAt,
}) {
return InfoItem(
type: type ?? this.type,
data: data ?? this.data,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
);
}
// Update with new data and timestamp
InfoItem update(InfoData newData) {
return copyWith(
data: newData,
updatedAt: DateTime.now(),
);
}
}

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

@@ -1,7 +1,6 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:locker/models/file_type.dart';
import 'package:locker/services/files/download/file_url.dart';
import 'package:locker/services/files/sync/models/file_magic.dart';
import 'package:logging/logging.dart';
@@ -24,7 +23,6 @@ class EnteFile {
String? thumbnailDecryptionHeader;
String? metadataDecryptionHeader;
int? fileSize;
FileType? fileType;
String? mMdEncodedJson;
int mMdVersion = 0;

View File

@@ -17,7 +17,6 @@ const motionVideoIndexKey = "mvi";
const noThumbKey = "noThumb";
const dateTimeKey = 'dateTime';
const offsetTimeKey = 'offsetTime';
const infoKey = 'info';
class MagicMetadata {
// 0 -> visible
@@ -75,11 +74,6 @@ class PubMagicMetadata {
// 1 -> panorama
int? mediaType;
// JSON containing information data for info files
// Contains type (note, physical-record, account-credential, emergency-contact)
// and data (the actual information content)
Map<String, dynamic>? info;
PubMagicMetadata({
this.editedTime,
this.editedName,
@@ -95,7 +89,6 @@ class PubMagicMetadata {
this.dateTime,
this.offsetTime,
this.sv,
this.info,
});
factory PubMagicMetadata.fromEncodedJson(String encodedJson) =>
@@ -121,7 +114,6 @@ class PubMagicMetadata {
dateTime: map[dateTimeKey],
offsetTime: map[offsetTimeKey],
sv: safeParseInt(map[streamVersionKey], streamVersionKey),
info: map[infoKey],
);
}

View File

@@ -97,66 +97,6 @@ class FileUploader {
return completer.future;
}
/// Special upload method for info files that contain only metadata
Future<EnteFile> uploadInfoFile(
EnteFile infoFile,
Collection collection,
) async {
try {
_logger.info('Starting upload of info file: ${infoFile.title}');
// Generate a file key for encryption
final fileKey = CryptoUtil.generateKey();
// Create metadata for the info file
final Map<String, dynamic> metadata = infoFile.metadata;
final encryptedMetadataResult = await CryptoUtil.encryptData(
utf8.encode(jsonEncode(metadata)),
fileKey,
);
final encryptedMetadata = CryptoUtil.bin2base64(
encryptedMetadataResult.encryptedData!,
);
final metadataDecryptionHeader = CryptoUtil.bin2base64(
encryptedMetadataResult.header!,
);
// Encrypt the file key with collection key
final encryptedFileKeyData = CryptoUtil.encryptSync(
fileKey,
CryptoHelper.instance.getCollectionKey(collection),
);
final encryptedKey =
CryptoUtil.bin2base64(encryptedFileKeyData.encryptedData!);
final keyDecryptionNonce =
CryptoUtil.bin2base64(encryptedFileKeyData.nonce!);
final pubMetadataRequest = await getPubMetadataRequest(
infoFile,
{'info': infoFile.pubMagicMetadata.info},
fileKey,
);
// Upload as metadata-only file (no file content or thumbnail)
final uploadedFile = await _uploadInfoFileMetadata(
infoFile,
collection.id,
encryptedKey,
keyDecryptionNonce,
encryptedMetadata,
metadataDecryptionHeader,
pubMetadataRequest,
);
_logger.info('Successfully uploaded info file: ${uploadedFile.title}');
return uploadedFile;
} catch (e, s) {
_logger.severe('Failed to upload info file: ${infoFile.title}', e, s);
rethrow;
}
}
int getCurrentSessionUploadCount() {
return _totalCountInUploadSession;
}
@@ -720,43 +660,6 @@ class FileUploader {
}
}
}
/// Upload method specifically for info files that don't require file content or thumbnails
Future<EnteFile> _uploadInfoFileMetadata(
EnteFile file,
int collectionID,
String encryptedKey,
String keyDecryptionNonce,
String encryptedMetadata,
String metadataDecryptionHeader,
MetadataRequest pubMetadata,
) async {
final request = {
"collectionID": collectionID,
"encryptedKey": encryptedKey,
"keyDecryptionNonce": keyDecryptionNonce,
"metadata": {
"encryptedData": encryptedMetadata,
"decryptionHeader": metadataDecryptionHeader,
},
"pubMagicMetadata": pubMetadata,
};
try {
final response = await _enteDio.post("/files/meta", data: request);
final data = response.data;
file.uploadedFileID = data["id"];
file.collectionID = collectionID;
file.updationTime = data["updationTime"];
file.ownerID = data["ownerID"];
file.encryptedKey = encryptedKey;
file.keyDecryptionNonce = keyDecryptionNonce;
file.metadataDecryptionHeader = metadataDecryptionHeader;
return file;
} catch (e, s) {
_logger.severe("Info file upload failed", e, s);
rethrow;
}
}
}
class FileUploadItem {

View File

@@ -1,170 +0,0 @@
import 'package:locker/models/file_type.dart';
import 'package:locker/models/info/info_item.dart';
import 'package:locker/services/collections/models/collection.dart';
import 'package:locker/services/files/sync/models/file.dart';
import 'package:locker/services/files/sync/models/file_magic.dart';
import 'package:locker/services/files/upload/file_upload_service.dart';
import 'package:logging/logging.dart';
class InfoFileService {
static final InfoFileService instance = InfoFileService._privateConstructor();
InfoFileService._privateConstructor();
final _logger = Logger('InfoFileService');
/// Creates and uploads an info file
Future<EnteFile> createAndUploadInfoFile({
required InfoItem infoItem,
required Collection collection,
}) async {
try {
// Create EnteFile object directly without a physical file
final enteFile = EnteFile();
enteFile.fileType = FileType.info;
enteFile.collectionID = collection.id;
// Set the title based on info type and data
enteFile.title = _getInfoFileTitle(infoItem);
// Set creation and modification times
final now = DateTime.now().millisecondsSinceEpoch;
enteFile.creationTime = now;
enteFile.modificationTime = now;
// Create public magic metadata with info data
final pubMagicMetadata = PubMagicMetadata(
info: {
'type': infoItem.type.name,
'data': infoItem.data.toJson(),
},
noThumb: true, // No thumbnail for info files
);
enteFile.pubMagicMetadata = pubMagicMetadata;
// Upload the file using the special info file upload method
final uploadedFile = await _uploadInfoFile(enteFile, collection);
_logger.info('Successfully uploaded info file: ${uploadedFile.title}');
return uploadedFile;
} catch (e, s) {
_logger.severe('Failed to create and upload info file', e, s);
rethrow;
}
}
/// Updates an existing info file with new data
Future<EnteFile> updateInfoFile({
required EnteFile existingFile,
required InfoItem updatedInfoItem,
}) async {
try {
// Update the public magic metadata
final updatedPubMagicMetadata = existingFile.pubMagicMetadata;
updatedPubMagicMetadata.info = {
'type': updatedInfoItem.type.name,
'data': updatedInfoItem.data.toJson(),
};
// Update the title
final updatedTitle = _getInfoFileTitle(updatedInfoItem);
updatedPubMagicMetadata.editedName = updatedTitle;
updatedPubMagicMetadata.editedTime =
DateTime.now().millisecondsSinceEpoch;
existingFile.pubMagicMetadata = updatedPubMagicMetadata;
// Update metadata on server
// This would call the metadata update service
// TODO: Implement metadata update and sync
_logger.info('Successfully updated info file: $updatedTitle');
return existingFile;
} catch (e, s) {
_logger.severe('Failed to update info file', e, s);
rethrow;
}
}
/// Extracts info data from a file
InfoItem? extractInfoFromFile(EnteFile file) {
try {
if (file.fileType != FileType.info ||
file.pubMagicMetadata.info == null) {
return null;
}
final infoData = file.pubMagicMetadata.info!;
final typeString = infoData['type'] as String?;
final data = infoData['data'] as Map<String, dynamic>?;
if (typeString == null || data == null) {
return null;
}
final infoType = InfoType.values.firstWhere(
(type) => type.name == typeString,
orElse: () => InfoType.note,
);
InfoData infoDataObj;
switch (infoType) {
case InfoType.note:
infoDataObj = PersonalNoteData.fromJson(data);
break;
case InfoType.physicalRecord:
infoDataObj = PhysicalRecordData.fromJson(data);
break;
case InfoType.accountCredential:
infoDataObj = AccountCredentialData.fromJson(data);
break;
case InfoType.emergencyContact:
infoDataObj = EmergencyContactData.fromJson(data);
break;
}
return InfoItem(
type: infoType,
data: infoDataObj,
createdAt: DateTime.now(),
);
} catch (e, s) {
_logger.severe('Failed to extract info from file', e, s);
return null;
}
}
/// Checks if a file is an info file
bool isInfoFile(EnteFile file) {
return file.fileType == FileType.info && file.pubMagicMetadata.info != null;
}
String _getInfoFileTitle(InfoItem infoItem) {
switch (infoItem.type) {
case InfoType.note:
final noteData = infoItem.data as PersonalNoteData;
return noteData.title.isNotEmpty ? noteData.title : 'Personal Note';
case InfoType.physicalRecord:
final recordData = infoItem.data as PhysicalRecordData;
return recordData.name.isNotEmpty ? recordData.name : 'Physical Record';
case InfoType.accountCredential:
final credData = infoItem.data as AccountCredentialData;
return credData.name.isNotEmpty
? '${credData.name} Account'
: 'Account Credential';
case InfoType.emergencyContact:
final contactData = infoItem.data as EmergencyContactData;
return contactData.name.isNotEmpty
? '${contactData.name} (Emergency Contact)'
: 'Emergency Contact';
}
}
/// Special upload method for info files that don't require physical file content
Future<EnteFile> _uploadInfoFile(
EnteFile enteFile,
Collection collection,
) async {
// Use the FileUploader's special method for info files
return await FileUploader.instance.uploadInfoFile(enteFile, collection);
}
}

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,224 +0,0 @@
import 'package:ente_ui/components/text_input_widget.dart';
import 'package:ente_ui/theme/ente_theme.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
/// A form-compatible wrapper that uses Ente UI TextInputWidget when possible,
/// or falls back to custom implementation for advanced features
class FormTextInputWidget extends StatefulWidget {
final TextEditingController controller;
final String labelText;
final String? hintText;
final String? Function(String?)? validator;
final bool obscureText;
final Widget? suffixIcon;
final int? maxLines;
final TextCapitalization textCapitalization;
final TextInputType? keyboardType;
final bool enabled;
final bool autofocus;
final int? maxLength;
final bool showValidationErrors;
const FormTextInputWidget({
super.key,
required this.controller,
required this.labelText,
this.hintText,
this.validator,
this.obscureText = false,
this.suffixIcon,
this.maxLines = 1,
this.textCapitalization = TextCapitalization.none,
this.keyboardType,
this.enabled = true,
this.autofocus = false,
this.maxLength,
this.showValidationErrors = false,
});
@override
State<FormTextInputWidget> createState() => _FormTextInputWidgetState();
}
class _FormTextInputWidgetState extends State<FormTextInputWidget> {
final GlobalKey<FormFieldState> _formFieldKey = GlobalKey<FormFieldState>();
String? _errorText;
@override
void initState() {
super.initState();
widget.controller.addListener(_onTextChanged);
}
@override
void dispose() {
widget.controller.removeListener(_onTextChanged);
super.dispose();
}
void _onTextChanged() {
if (_errorText != null) {
setState(() {
_errorText = null;
});
}
// Only validate if we should show validation errors
if (widget.showValidationErrors) {
_formFieldKey.currentState?.validate();
}
}
// Check if we can use the UI package's TextInputWidget
bool get _canUseTextInputWidget {
return widget.suffixIcon == null &&
(widget.maxLines ?? 1) == 1 &&
widget.enabled;
}
@override
Widget build(BuildContext context) {
final colorScheme = getEnteColorScheme(context);
final textTheme = getEnteTextTheme(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (_canUseTextInputWidget) ...[
// Use the UI package's TextInputWidget for simple cases
TextInputWidget(
label: widget.labelText,
hintText: widget.hintText,
initialValue: widget.controller.text,
isPasswordInput: widget.obscureText,
textCapitalization: widget.textCapitalization,
autoFocus: widget.autofocus,
maxLength: widget.maxLength,
shouldSurfaceExecutionStates: false,
onChange: (value) {
if (widget.controller.text != value) {
widget.controller.text = value;
}
},
),
] else ...[
// Custom implementation for advanced features
if (widget.labelText.isNotEmpty) ...[
Text(widget.labelText),
const SizedBox(height: 4),
],
ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: Material(
color: Colors.transparent,
child: TextFormField(
controller: widget.controller,
validator: (value) => null, // Handled separately
obscureText: widget.obscureText,
maxLines: widget.obscureText ? 1 : widget.maxLines,
textCapitalization: widget.textCapitalization,
keyboardType: widget.keyboardType,
enabled: widget.enabled,
autofocus: widget.autofocus,
inputFormatters: widget.maxLength != null
? [LengthLimitingTextInputFormatter(widget.maxLength!)]
: null,
style: textTheme.body,
decoration: InputDecoration(
hintText: widget.hintText,
hintStyle:
textTheme.body.copyWith(color: colorScheme.textMuted),
filled: true,
fillColor: colorScheme.fillFaint,
contentPadding: const EdgeInsets.fromLTRB(16, 16, 16, 16),
border: const UnderlineInputBorder(
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderSide: BorderSide(
color: colorScheme.strokeFaint,
width: 1,
),
borderRadius: BorderRadius.circular(8),
),
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(
color: colorScheme.primary500,
width: 2,
),
borderRadius: BorderRadius.circular(8),
),
errorBorder: OutlineInputBorder(
borderSide: BorderSide(
color: colorScheme.warning500,
width: 1,
),
borderRadius: BorderRadius.circular(8),
),
focusedErrorBorder: OutlineInputBorder(
borderSide: BorderSide(
color: colorScheme.warning500,
width: 2,
),
borderRadius: BorderRadius.circular(8),
),
suffixIcon: widget.suffixIcon != null
? Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: widget.suffixIcon,
)
: null,
suffixIconConstraints: const BoxConstraints(
maxHeight: 24,
maxWidth: 48,
minHeight: 24,
minWidth: 48,
),
errorStyle: const TextStyle(fontSize: 0, height: 0),
),
),
),
),
],
// Custom validation error display (for both cases)
if (_errorText != null && widget.showValidationErrors) ...[
const SizedBox(height: 4),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text(
_errorText!,
style: textTheme.mini.copyWith(
color: colorScheme.warning500,
),
),
),
],
// Invisible FormField for validation integration
SizedBox(
height: 0,
child: FormField<String>(
key: _formFieldKey,
validator: (value) {
final error = widget.validator?.call(widget.controller.text);
if (mounted && widget.showValidationErrors) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
setState(() {
_errorText = error;
});
}
});
}
return error;
},
builder: (FormFieldState<String> field) {
return const SizedBox.shrink();
},
),
),
],
);
}
}

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

@@ -1,357 +0,0 @@
import 'package:ente_ui/components/buttons/gradient_button.dart';
import 'package:ente_ui/theme/ente_theme.dart';
import 'package:flutter/material.dart';
import 'package:locker/l10n/l10n.dart';
import 'package:locker/models/info/info_item.dart';
import 'package:locker/services/collections/collections_service.dart';
import 'package:locker/services/collections/models/collection.dart';
import 'package:locker/services/info_file_service.dart';
import 'package:locker/ui/components/collection_selection_widget.dart';
import 'package:locker/ui/components/form_text_input_widget.dart';
class AccountCredentialsPage extends StatefulWidget {
const AccountCredentialsPage({super.key});
@override
State<AccountCredentialsPage> createState() => _AccountCredentialsPageState();
}
class _AccountCredentialsPageState extends State<AccountCredentialsPage> {
final TextEditingController _nameController = TextEditingController();
final TextEditingController _usernameController = TextEditingController();
final TextEditingController _passwordController = TextEditingController();
final TextEditingController _notesController = TextEditingController();
final _formKey = GlobalKey<FormState>();
bool _isLoading = false;
bool _passwordVisible = false;
bool _showValidationErrors = false;
final _passwordFocusNode = FocusNode();
bool _passwordInFocus = false;
// Collection selection state
List<Collection> _availableCollections = [];
Set<int> _selectedCollectionIds = {};
@override
void initState() {
super.initState();
_passwordFocusNode.addListener(() {
setState(() {
_passwordInFocus = _passwordFocusNode.hasFocus;
});
});
_loadCollections();
}
Future<void> _loadCollections() async {
try {
final collections = await CollectionService.instance.getCollections();
setState(() {
_availableCollections = collections;
// Pre-select a default collection if available
if (collections.isNotEmpty) {
final defaultCollection = collections.firstWhere(
(c) => c.name == 'Information',
orElse: () => collections.first,
);
_selectedCollectionIds = {defaultCollection.id};
}
});
} catch (e) {
// Handle error silently or show a message
}
}
void _onToggleCollection(int collectionId) {
setState(() {
if (_selectedCollectionIds.contains(collectionId)) {
_selectedCollectionIds.remove(collectionId);
} else {
// Allow multiple selections
_selectedCollectionIds.add(collectionId);
}
});
}
void _onCollectionsUpdated(List<Collection> updatedCollections) {
setState(() {
_availableCollections = updatedCollections;
});
}
@override
void dispose() {
_nameController.dispose();
_usernameController.dispose();
_passwordController.dispose();
_notesController.dispose();
_passwordFocusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(
context.l10n.accountCredentials,
style: const TextStyle(fontWeight: FontWeight.bold),
),
elevation: 0,
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
foregroundColor: Theme.of(context).textTheme.bodyLarge?.color,
),
body: Form(
key: _formKey,
child: Column(
children: [
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.accountCredentialsDescription,
style: getEnteTextTheme(context).body.copyWith(
color: Colors.grey[600],
),
),
const SizedBox(height: 32),
FormTextInputWidget(
controller: _nameController,
labelText: context.l10n.credentialName,
hintText: context.l10n.credentialNameHint,
showValidationErrors: _showValidationErrors,
validator: (value) {
if (value == null || value.trim().isEmpty) {
return context.l10n.pleaseEnterAccountName;
}
return null;
},
),
const SizedBox(height: 24),
FormTextInputWidget(
controller: _usernameController,
labelText: context.l10n.username,
hintText: context.l10n.usernameHint,
showValidationErrors: _showValidationErrors,
validator: (value) {
if (value == null || value.trim().isEmpty) {
return context.l10n.pleaseEnterUsername;
}
return null;
},
),
const SizedBox(height: 24),
Text(context.l10n.password),
const SizedBox(height: 4),
Padding(
padding: const EdgeInsets.fromLTRB(0, 0, 0, 0),
child: TextFormField(
controller: _passwordController,
focusNode: _passwordFocusNode,
obscureText: !_passwordVisible,
keyboardType: TextInputType.visiblePassword,
style: getEnteTextTheme(context).body,
decoration: InputDecoration(
fillColor: getEnteColorScheme(context).fillFaint,
filled: true,
hintText: context.l10n.passwordHint,
hintStyle: getEnteTextTheme(context).body.copyWith(
color: getEnteColorScheme(context).textMuted,
),
contentPadding:
const EdgeInsets.fromLTRB(16, 16, 16, 16),
border: const UnderlineInputBorder(
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderSide: BorderSide(
color: getEnteColorScheme(context).strokeFaint,
width: 1,
),
borderRadius: BorderRadius.circular(8),
),
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(
color: getEnteColorScheme(context).primary500,
width: 2,
),
borderRadius: BorderRadius.circular(8),
),
errorBorder: OutlineInputBorder(
borderSide: BorderSide(
color: getEnteColorScheme(context).warning500,
width: 1,
),
borderRadius: BorderRadius.circular(8),
),
focusedErrorBorder: OutlineInputBorder(
borderSide: BorderSide(
color: getEnteColorScheme(context).warning500,
width: 2,
),
borderRadius: BorderRadius.circular(8),
),
suffixIcon: _passwordInFocus
? IconButton(
icon: Icon(
_passwordVisible
? Icons.visibility
: Icons.visibility_off,
color: Theme.of(context).iconTheme.color,
size: 20,
),
onPressed: () {
setState(() {
_passwordVisible = !_passwordVisible;
});
},
)
: null,
suffixIconConstraints: const BoxConstraints(
maxHeight: 24,
maxWidth: 48,
minHeight: 24,
minWidth: 48,
),
),
validator: _showValidationErrors
? (value) {
if (value == null || value.trim().isEmpty) {
return context.l10n.pleaseEnterPassword;
}
return null;
}
: null,
onChanged: (value) {
if (_showValidationErrors) {
setState(() {});
}
},
),
),
const SizedBox(height: 24),
FormTextInputWidget(
controller: _notesController,
labelText: context.l10n.credentialNotes,
hintText: context.l10n.credentialNotesHint,
maxLines: 5,
showValidationErrors: _showValidationErrors,
),
const SizedBox(height: 24),
CollectionSelectionWidget(
collections: _availableCollections,
selectedCollectionIds: _selectedCollectionIds,
onToggleCollection: _onToggleCollection,
onCollectionsUpdated: _onCollectionsUpdated,
),
],
),
),
),
Container(
padding: const EdgeInsets.all(24),
child: SizedBox(
width: double.infinity,
child: GradientButton(
onTap: _isLoading ? null : _saveRecord,
text: context.l10n.saveRecord,
paddingValue: 16.0,
),
),
),
],
),
),
);
}
Future<void> _saveRecord() async {
setState(() {
_showValidationErrors = true;
});
if (!_formKey.currentState!.validate()) {
return;
}
if (_selectedCollectionIds.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Please select at least one collection'),
backgroundColor: Colors.red,
),
);
return;
}
setState(() {
_isLoading = true;
});
try {
// Create InfoItem for account credentials
final credentialData = AccountCredentialData(
name: _nameController.text.trim(),
username: _usernameController.text.trim(),
password: _passwordController.text.trim(),
notes: _notesController.text.trim().isNotEmpty
? _notesController.text.trim()
: null,
);
final infoItem = InfoItem(
type: InfoType.accountCredential,
data: credentialData,
createdAt: DateTime.now(),
);
// Upload to all selected collections
final selectedCollections = _availableCollections
.where((c) => _selectedCollectionIds.contains(c.id))
.toList();
// Create and upload the info file to each selected collection
for (final collection in selectedCollections) {
await InfoFileService.instance.createAndUploadInfoFile(
infoItem: infoItem,
collection: collection,
);
}
if (mounted) {
Navigator.of(context).pop(); // Go back to information page
// Show success message
final collectionCount = selectedCollections.length;
final message = collectionCount == 1
? context.l10n.recordSavedSuccessfully
: 'Record saved to $collectionCount collections successfully';
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('${context.l10n.failedToSaveRecord}: $e'),
backgroundColor: Colors.red,
),
);
}
} finally {
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
}
}

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,258 +0,0 @@
import 'package:ente_ui/components/buttons/gradient_button.dart';
import 'package:ente_ui/theme/ente_theme.dart';
import 'package:flutter/material.dart';
import 'package:locker/l10n/l10n.dart';
import 'package:locker/models/info/info_item.dart';
import 'package:locker/services/collections/collections_service.dart';
import 'package:locker/services/collections/models/collection.dart';
import 'package:locker/services/info_file_service.dart';
import 'package:locker/ui/components/collection_selection_widget.dart';
import 'package:locker/ui/components/form_text_input_widget.dart';
class EmergencyContactPage extends StatefulWidget {
const EmergencyContactPage({super.key});
@override
State<EmergencyContactPage> createState() => _EmergencyContactPageState();
}
class _EmergencyContactPageState extends State<EmergencyContactPage> {
final TextEditingController _nameController = TextEditingController();
final TextEditingController _contactDetailsController =
TextEditingController();
final TextEditingController _notesController = TextEditingController();
final _formKey = GlobalKey<FormState>();
bool _isLoading = false;
bool _showValidationErrors = false;
// Collection selection state
List<Collection> _availableCollections = [];
Set<int> _selectedCollectionIds = {};
@override
void initState() {
super.initState();
_loadCollections();
}
Future<void> _loadCollections() async {
try {
final collections = await CollectionService.instance.getCollections();
setState(() {
_availableCollections = collections;
// Pre-select a default collection if available
if (collections.isNotEmpty) {
final defaultCollection = collections.firstWhere(
(c) => c.name == 'Information',
orElse: () => collections.first,
);
_selectedCollectionIds = {defaultCollection.id};
}
});
} catch (e) {
// Handle error silently or show a message
}
}
void _onToggleCollection(int collectionId) {
setState(() {
if (_selectedCollectionIds.contains(collectionId)) {
_selectedCollectionIds.remove(collectionId);
} else {
// Allow multiple selections
_selectedCollectionIds.add(collectionId);
}
});
}
void _onCollectionsUpdated(List<Collection> updatedCollections) {
setState(() {
_availableCollections = updatedCollections;
});
}
@override
void dispose() {
_nameController.dispose();
_contactDetailsController.dispose();
_notesController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(
context.l10n.emergencyContact,
style: const TextStyle(fontWeight: FontWeight.bold),
),
elevation: 0,
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
foregroundColor: Theme.of(context).textTheme.bodyLarge?.color,
),
body: Form(
key: _formKey,
child: Column(
children: [
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.emergencyContactDescription,
style: getEnteTextTheme(context).body.copyWith(
color: Colors.grey[600],
),
),
const SizedBox(height: 32),
FormTextInputWidget(
controller: _nameController,
labelText: context.l10n.contactName,
hintText: context.l10n.contactNameHint,
showValidationErrors: _showValidationErrors,
validator: (value) {
if (value == null || value.trim().isEmpty) {
return context.l10n.pleaseEnterContactName;
}
return null;
},
),
const SizedBox(height: 24),
FormTextInputWidget(
controller: _contactDetailsController,
labelText: context.l10n.contactDetails,
hintText: context.l10n.contactDetailsHint,
maxLines: 3,
showValidationErrors: _showValidationErrors,
validator: (value) {
if (value == null || value.trim().isEmpty) {
return context.l10n.pleaseEnterContactDetails;
}
return null;
},
),
const SizedBox(height: 24),
FormTextInputWidget(
controller: _notesController,
labelText: context.l10n.contactNotes,
hintText: context.l10n.contactNotesHint,
maxLines: 4,
showValidationErrors: _showValidationErrors,
),
const SizedBox(height: 24),
CollectionSelectionWidget(
collections: _availableCollections,
selectedCollectionIds: _selectedCollectionIds,
onToggleCollection: _onToggleCollection,
onCollectionsUpdated: _onCollectionsUpdated,
),
],
),
),
),
Container(
padding: const EdgeInsets.all(24),
child: SizedBox(
width: double.infinity,
child: GradientButton(
onTap: _isLoading ? null : _saveRecord,
text: context.l10n.saveRecord,
paddingValue: 16.0,
),
),
),
],
),
),
);
}
Future<void> _saveRecord() async {
setState(() {
_showValidationErrors = true;
});
if (!_formKey.currentState!.validate()) {
return;
}
if (_selectedCollectionIds.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Please select at least one collection'),
backgroundColor: Colors.red,
),
);
return;
}
setState(() {
_isLoading = true;
});
try {
// Create InfoItem for emergency contact
final contactData = EmergencyContactData(
name: _nameController.text.trim(),
contactDetails: _contactDetailsController.text.trim(),
notes: _notesController.text.trim().isNotEmpty
? _notesController.text.trim()
: null,
);
final infoItem = InfoItem(
type: InfoType.emergencyContact,
data: contactData,
createdAt: DateTime.now(),
);
// Upload to all selected collections
final selectedCollections = _availableCollections
.where((c) => _selectedCollectionIds.contains(c.id))
.toList();
// Create and upload the info file to each selected collection
for (final collection in selectedCollections) {
await InfoFileService.instance.createAndUploadInfoFile(
infoItem: infoItem,
collection: collection,
);
}
if (mounted) {
Navigator.of(context).pop(); // Go back to information page
// Show success message
final collectionCount = selectedCollections.length;
final message = collectionCount == 1
? context.l10n.recordSavedSuccessfully
: 'Record saved to $collectionCount collections successfully';
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('${context.l10n.failedToSaveRecord}: $e'),
backgroundColor: Colors.red,
),
);
}
} finally {
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
}
}

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,17 +15,17 @@ 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';
import 'package:locker/ui/pages/all_collections_page.dart';
import 'package:locker/ui/pages/collection_page.dart';
import 'package:locker/ui/pages/information_page.dart';
import "package:locker/ui/pages/settings_page.dart";
import 'package:locker/ui/pages/uploader_page.dart';
import 'package:locker/utils/collection_actions.dart';
import 'package:locker/utils/collection_sort_util.dart';
import "package:locker/utils/snack_bar_utils.dart";
import 'package:logging/logging.dart';
class HomePage extends UploaderPage {
@@ -51,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;
@@ -89,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);
}
@@ -269,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;
});
@@ -492,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(),
],
),
@@ -558,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,
@@ -687,41 +626,34 @@ class _HomePageState extends UploaderPageState<HomePage>
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
GestureDetector(
onTap: () {
_toggleFab();
_showInformationDialog();
},
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: getEnteColorScheme(context).fillBase,
borderRadius: BorderRadius.circular(16),
),
child: Text(
context.l10n.saveInformation,
style:
getEnteTextTheme(context).small.copyWith(
color: getEnteColorScheme(context)
.backgroundBase,
),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: getEnteColorScheme(context).fillBase,
borderRadius: BorderRadius.circular(16),
),
child: Text(
context.l10n.createCollectionTooltip,
style: getEnteTextTheme(context).small.copyWith(
color: getEnteColorScheme(context)
.backgroundBase,
),
),
),
const SizedBox(width: 8),
FloatingActionButton(
heroTag: "information",
heroTag: "createCollection",
mini: true,
onPressed: () {
_toggleFab();
_showInformationDialog();
_createCollection();
},
backgroundColor:
getEnteColorScheme(context).fillBase,
child: const Icon(Icons.edit_document),
child: const Icon(Icons.create_new_folder),
),
],
),
@@ -735,29 +667,22 @@ class _HomePageState extends UploaderPageState<HomePage>
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
GestureDetector(
onTap: () {
_toggleFab();
addFile();
},
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: getEnteColorScheme(context).fillBase,
borderRadius: BorderRadius.circular(16),
),
child: Text(
context.l10n.uploadDocumentTooltip,
style: getEnteTextTheme(context)
.small
.copyWith(
color: getEnteColorScheme(context)
.backgroundBase,
),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: getEnteColorScheme(context).fillBase,
borderRadius: BorderRadius.circular(16),
),
child: Text(
context.l10n.uploadDocumentTooltip,
style:
getEnteTextTheme(context).small.copyWith(
color: getEnteColorScheme(context)
.backgroundBase,
),
),
),
const SizedBox(width: 8),
@@ -805,30 +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;
});
}
}
void _showInformationDialog() {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const InformationPage(),
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

@@ -1,157 +0,0 @@
import 'package:ente_ui/theme/ente_theme.dart';
import 'package:flutter/material.dart';
import 'package:locker/l10n/l10n.dart';
import 'package:locker/ui/pages/account_credentials_page.dart';
import 'package:locker/ui/pages/emergency_contact_page.dart';
import 'package:locker/ui/pages/personal_note_page.dart';
import 'package:locker/ui/pages/physical_records_page.dart';
enum InformationType {
note,
physicalRecord,
credentials,
emergencyContact,
}
class InformationPage extends StatelessWidget {
const InformationPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(
context.l10n.saveInformation,
style: const TextStyle(fontWeight: FontWeight.bold),
),
elevation: 0,
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
foregroundColor: Theme.of(context).textTheme.bodyLarge?.color,
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.informationDescription,
style: getEnteTextTheme(context).body.copyWith(
color: Colors.grey[600],
),
),
const SizedBox(height: 32),
_buildInformationOption(
context,
icon: Icons.notes,
title: context.l10n.personalNote,
description: context.l10n.personalNoteDescription,
type: InformationType.note,
),
const SizedBox(height: 16),
_buildInformationOption(
context,
icon: Icons.folder_outlined,
title: context.l10n.physicalRecords,
description: context.l10n.physicalRecordsDescription,
type: InformationType.physicalRecord,
),
const SizedBox(height: 16),
_buildInformationOption(
context,
icon: Icons.lock,
title: context.l10n.accountCredentials,
description: context.l10n.accountCredentialsDescription,
type: InformationType.credentials,
),
const SizedBox(height: 16),
_buildInformationOption(
context,
icon: Icons.contact_phone,
title: context.l10n.emergencyContact,
description: context.l10n.emergencyContactDescription,
type: InformationType.emergencyContact,
),
const SizedBox(height: 32),
],
),
),
);
}
Widget _buildInformationOption(
BuildContext context, {
required IconData icon,
required String title,
required String description,
required InformationType type,
}) {
return GestureDetector(
onTap: () {
_showInformationForm(context, type);
},
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: getEnteColorScheme(context).fillFaint,
),
child: Row(
children: [
Icon(
icon,
size: 24,
color: getEnteColorScheme(context).primary500,
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: getEnteTextTheme(context).body.copyWith(
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 4),
Text(
description,
style: getEnteTextTheme(context).small.copyWith(
color: Colors.grey[600],
),
),
],
),
),
Icon(
Icons.chevron_right,
color: Colors.grey[400],
),
],
),
),
);
}
void _showInformationForm(BuildContext context, InformationType type) {
Widget page;
switch (type) {
case InformationType.note:
page = const PersonalNotePage();
break;
case InformationType.physicalRecord:
page = const PhysicalRecordsPage();
break;
case InformationType.credentials:
page = const AccountCredentialsPage();
break;
case InformationType.emergencyContact:
page = const EmergencyContactPage();
break;
}
Navigator.of(context).push(
MaterialPageRoute(builder: (context) => page),
);
}
}

View File

@@ -1,245 +0,0 @@
import 'package:ente_ui/components/buttons/gradient_button.dart';
import 'package:ente_ui/theme/ente_theme.dart';
import 'package:flutter/material.dart';
import 'package:locker/l10n/l10n.dart';
import 'package:locker/models/info/info_item.dart';
import 'package:locker/services/collections/collections_service.dart';
import 'package:locker/services/collections/models/collection.dart';
import 'package:locker/services/info_file_service.dart';
import 'package:locker/ui/components/collection_selection_widget.dart';
import 'package:locker/ui/components/form_text_input_widget.dart';
class PersonalNotePage extends StatefulWidget {
const PersonalNotePage({super.key});
@override
State<PersonalNotePage> createState() => _PersonalNotePageState();
}
class _PersonalNotePageState extends State<PersonalNotePage> {
final TextEditingController _nameController = TextEditingController();
final TextEditingController _contentController = TextEditingController();
final _formKey = GlobalKey<FormState>();
bool _isLoading = false;
bool _showValidationErrors = false;
// Collection selection state
List<Collection> _availableCollections = [];
Set<int> _selectedCollectionIds = {};
@override
void initState() {
super.initState();
_loadCollections();
}
Future<void> _loadCollections() async {
try {
final collections = await CollectionService.instance.getCollections();
setState(() {
_availableCollections = collections;
// Pre-select a default collection if available
if (collections.isNotEmpty) {
final defaultCollection = collections.firstWhere(
(c) => c.name == 'Information',
orElse: () => collections.first,
);
_selectedCollectionIds = {defaultCollection.id};
}
});
} catch (e) {
// Handle error silently or show a message
}
}
void _onToggleCollection(int collectionId) {
setState(() {
if (_selectedCollectionIds.contains(collectionId)) {
_selectedCollectionIds.remove(collectionId);
} else {
// Allow multiple selections
_selectedCollectionIds.add(collectionId);
}
});
}
void _onCollectionsUpdated(List<Collection> updatedCollections) {
setState(() {
_availableCollections = updatedCollections;
});
}
@override
void dispose() {
_nameController.dispose();
_contentController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(
context.l10n.personalNote,
style: const TextStyle(fontWeight: FontWeight.bold),
),
elevation: 0,
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
foregroundColor: Theme.of(context).textTheme.bodyLarge?.color,
),
body: Form(
key: _formKey,
child: Column(
children: [
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.personalNoteDescription,
style: getEnteTextTheme(context).body.copyWith(
color: Colors.grey[600],
),
),
const SizedBox(height: 32),
FormTextInputWidget(
controller: _nameController,
labelText: context.l10n.noteName,
hintText: context.l10n.noteNameHint,
showValidationErrors: _showValidationErrors,
validator: (value) {
if (value == null || value.trim().isEmpty) {
return context.l10n.pleaseEnterNoteName;
}
return null;
},
),
const SizedBox(height: 24),
FormTextInputWidget(
controller: _contentController,
labelText: context.l10n.noteContent,
hintText: context.l10n.noteContentHint,
maxLines: 6,
showValidationErrors: _showValidationErrors,
validator: (value) {
if (value == null || value.trim().isEmpty) {
return context.l10n.pleaseEnterNoteContent;
}
return null;
},
),
const SizedBox(height: 24),
CollectionSelectionWidget(
collections: _availableCollections,
selectedCollectionIds: _selectedCollectionIds,
onToggleCollection: _onToggleCollection,
onCollectionsUpdated: _onCollectionsUpdated,
),
],
),
),
),
Container(
padding: const EdgeInsets.all(24),
child: SizedBox(
width: double.infinity,
child: GradientButton(
onTap: _isLoading ? null : _saveRecord,
text: context.l10n.saveRecord,
paddingValue: 16.0,
),
),
),
],
),
),
);
}
Future<void> _saveRecord() async {
setState(() {
_showValidationErrors = true;
});
if (!_formKey.currentState!.validate()) {
return;
}
if (_selectedCollectionIds.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Please select at least one collection'),
backgroundColor: Colors.red,
),
);
return;
}
setState(() {
_isLoading = true;
});
try {
// Create InfoItem for personal note
final noteData = PersonalNoteData(
title: _nameController.text.trim(),
content: _contentController.text.trim(),
);
final infoItem = InfoItem(
type: InfoType.note,
data: noteData,
createdAt: DateTime.now(),
);
// Upload to all selected collections
final selectedCollections = _availableCollections
.where((c) => _selectedCollectionIds.contains(c.id))
.toList();
// Create and upload the info file to each selected collection
for (final collection in selectedCollections) {
await InfoFileService.instance.createAndUploadInfoFile(
infoItem: infoItem,
collection: collection,
);
}
if (mounted) {
Navigator.of(context)
.pop(); // Close this page and return to information page
// Show success message
final collectionCount = selectedCollections.length;
final message = collectionCount == 1
? context.l10n.recordSavedSuccessfully
: 'Record saved to $collectionCount collections successfully';
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('${context.l10n.failedToSaveRecord}: $e'),
backgroundColor: Colors.red,
),
);
}
} finally {
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
}
}

View File

@@ -1,257 +0,0 @@
import 'package:ente_ui/components/buttons/gradient_button.dart';
import 'package:ente_ui/theme/ente_theme.dart';
import 'package:flutter/material.dart';
import 'package:locker/l10n/l10n.dart';
import 'package:locker/models/info/info_item.dart';
import 'package:locker/services/collections/collections_service.dart';
import 'package:locker/services/collections/models/collection.dart';
import 'package:locker/services/info_file_service.dart';
import 'package:locker/ui/components/collection_selection_widget.dart';
import 'package:locker/ui/components/form_text_input_widget.dart';
class PhysicalRecordsPage extends StatefulWidget {
const PhysicalRecordsPage({super.key});
@override
State<PhysicalRecordsPage> createState() => _PhysicalRecordsPageState();
}
class _PhysicalRecordsPageState extends State<PhysicalRecordsPage> {
final TextEditingController _nameController = TextEditingController();
final TextEditingController _locationController = TextEditingController();
final TextEditingController _notesController = TextEditingController();
final _formKey = GlobalKey<FormState>();
bool _isLoading = false;
bool _showValidationErrors = false;
// Collection selection state
List<Collection> _availableCollections = [];
Set<int> _selectedCollectionIds = {};
@override
void initState() {
super.initState();
_loadCollections();
}
Future<void> _loadCollections() async {
try {
final collections = await CollectionService.instance.getCollections();
setState(() {
_availableCollections = collections;
// Pre-select a default collection if available
if (collections.isNotEmpty) {
final defaultCollection = collections.firstWhere(
(c) => c.name == 'Information',
orElse: () => collections.first,
);
_selectedCollectionIds = {defaultCollection.id};
}
});
} catch (e) {
// Handle error silently or show a message
}
}
void _onToggleCollection(int collectionId) {
setState(() {
if (_selectedCollectionIds.contains(collectionId)) {
_selectedCollectionIds.remove(collectionId);
} else {
// Allow multiple selections
_selectedCollectionIds.add(collectionId);
}
});
}
void _onCollectionsUpdated(List<Collection> updatedCollections) {
setState(() {
_availableCollections = updatedCollections;
});
}
@override
void dispose() {
_nameController.dispose();
_locationController.dispose();
_notesController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(
context.l10n.physicalRecords,
style: const TextStyle(fontWeight: FontWeight.bold),
),
elevation: 0,
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
foregroundColor: Theme.of(context).textTheme.bodyLarge?.color,
),
body: Form(
key: _formKey,
child: Column(
children: [
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.physicalRecordsDescription,
style: getEnteTextTheme(context).body.copyWith(
color: Colors.grey[600],
),
),
const SizedBox(height: 32),
FormTextInputWidget(
controller: _nameController,
labelText: context.l10n.recordName,
hintText: context.l10n.recordNameHint,
showValidationErrors: _showValidationErrors,
validator: (value) {
if (value == null || value.trim().isEmpty) {
return context.l10n.pleaseEnterRecordName;
}
return null;
},
),
const SizedBox(height: 24),
FormTextInputWidget(
controller: _locationController,
labelText: context.l10n.recordLocation,
hintText: context.l10n.recordLocationHint,
maxLines: 3,
showValidationErrors: _showValidationErrors,
validator: (value) {
if (value == null || value.trim().isEmpty) {
return context.l10n.pleaseEnterLocation;
}
return null;
},
),
const SizedBox(height: 24),
FormTextInputWidget(
controller: _notesController,
labelText: context.l10n.recordNotes,
hintText: context.l10n.recordNotesHint,
maxLines: 5,
showValidationErrors: _showValidationErrors,
),
const SizedBox(height: 24),
CollectionSelectionWidget(
collections: _availableCollections,
selectedCollectionIds: _selectedCollectionIds,
onToggleCollection: _onToggleCollection,
onCollectionsUpdated: _onCollectionsUpdated,
),
],
),
),
),
Container(
padding: const EdgeInsets.all(24),
child: SizedBox(
width: double.infinity,
child: GradientButton(
onTap: _isLoading ? null : _saveRecord,
text: context.l10n.saveRecord,
paddingValue: 16.0,
),
),
),
],
),
),
);
}
Future<void> _saveRecord() async {
setState(() {
_showValidationErrors = true;
});
if (!_formKey.currentState!.validate()) {
return;
}
if (_selectedCollectionIds.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Please select at least one collection'),
backgroundColor: Colors.red,
),
);
return;
}
setState(() {
_isLoading = true;
});
try {
// Create InfoItem for physical record
final recordData = PhysicalRecordData(
name: _nameController.text.trim(),
location: _locationController.text.trim(),
notes: _notesController.text.trim().isNotEmpty
? _notesController.text.trim()
: null,
);
final infoItem = InfoItem(
type: InfoType.physicalRecord,
data: recordData,
createdAt: DateTime.now(),
);
// Upload to all selected collections
final selectedCollections = _availableCollections
.where((c) => _selectedCollectionIds.contains(c.id))
.toList();
// Create and upload the info file to each selected collection
for (final collection in selectedCollections) {
await InfoFileService.instance.createAndUploadInfoFile(
infoItem: infoItem,
collection: collection,
);
}
if (mounted) {
Navigator.of(context).pop(); // Go back to information page
// Show success message
final collectionCount = selectedCollections.length;
final message = collectionCount == 1
? context.l10n.recordSavedSuccessfully
: 'Record saved to $collectionCount collections successfully';
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('${context.l10n.failedToSaveRecord}: $e'),
backgroundColor: Colors.red,
),
);
}
} finally {
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
}
}

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
@@ -75,6 +83,7 @@ flutter:
assets:
- assets/
- assets/icons/
fonts:
- family: Inter

View File

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

View File

@@ -1,6 +1,6 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
This file provides guidance to Claude, Codex, and any other agent when working with code in this repository.
## Project Philosophy
@@ -31,7 +31,10 @@ The Photos app uses two types of packages:
**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
# 1. Format Dart code
dart format .
# 2. Analyze flutter code for errors and warnings
flutter analyze
```
@@ -164,11 +167,12 @@ lib/
## Critical Coding Requirements
### 1. Code Quality - MANDATORY
**Every code change MUST pass `flutter analyze` with zero issues**
**Every code change MUST pass `dart format .` and `flutter analyze` with zero issues**
- Run `dart format .` first to format all Dart code
- Run `flutter analyze` after EVERY code modification
- Resolve ALL issues (info, warning, error) - no exceptions
- The codebase has zero issues by default, so any issue is from your changes
- DO NOT commit or consider work complete until `flutter analyze` passes cleanly
- DO NOT commit or consider work complete until both commands pass cleanly
### 2. Component Reuse - MANDATORY
**Always try to reuse existing components**
@@ -192,6 +196,11 @@ lib/
- Ensure documentation reflects the current implementation
- Update examples in specs if behavior changes
### 5. Database Methods - BEST PRACTICE
**Prioritize readability in database methods**
- For small result sets (e.g., 1-2 stale entries), prefer filtering in Dart for cleaner, more readable code
- For large datasets, use SQL WHERE clauses for performance - they're much more efficient in SQLite
## Important Notes
- Large service files (some 70k+ lines) - consider file context when editing
@@ -201,4 +210,4 @@ lib/
- Always follow existing code conventions and patterns in neighboring files
# Individual Preferences
- @~/.claude/my-project-instructions.md
- @~/.claude/ente-photos-instructions.md

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -62,6 +62,7 @@ class MLDataDB with SqlDbBase implements IMLDataDB<int> {
createClipEmbeddingsTable,
createFileDataTable,
createFaceCacheTable,
createTextEmbeddingsCacheTable,
];
// only have a single app-wide reference to the database
@@ -1429,6 +1430,56 @@ class MLDataDB with SqlDbBase implements IMLDataDB<int> {
Bus.instance.fire(EmbeddingUpdatedEvent());
}
/// WARNING: don't confuse this with [putClip]. If you're not sure, use [putClip]
Future<void> putRepeatedTextEmbeddingCache(
String query,
List<double> embedding,
) async {
final db = await asyncDB;
await db.execute(
'INSERT OR REPLACE INTO $textEmbeddingsCacheTable '
'($textQueryColumn, $embeddingColumn, $mlVersionColumn, $createdAtColumn) '
'VALUES (?, ?, ?, ?)',
[
query,
Float32List.fromList(embedding).buffer.asUint8List(),
clipMlVersion,
DateTime.now().millisecondsSinceEpoch,
],
);
}
/// WARNING: don't confuse this with [getAllClipVectors]. If you're not sure, use [getAllClipVectors]
Future<List<double>?> getRepeatedTextEmbeddingCache(String query) async {
final db = await asyncDB;
final results = await db.getAll(
'SELECT $embeddingColumn, $mlVersionColumn, $createdAtColumn '
'FROM $textEmbeddingsCacheTable '
'WHERE $textQueryColumn = ?',
[query],
);
if (results.isEmpty) return null;
final threeMonthsAgo =
DateTime.now().millisecondsSinceEpoch - (90 * 24 * 60 * 60 * 1000);
// Find first valid entry
for (final result in results) {
if (result[mlVersionColumn] == clipMlVersion &&
result[createdAtColumn] as int > threeMonthsAgo) {
return Float32List.view((result[embeddingColumn] as Uint8List).buffer);
}
}
// No valid entry found, clean up
await db.execute(
'DELETE FROM $textEmbeddingsCacheTable WHERE $textQueryColumn = ?',
[query],
);
return null;
}
@override
Future<void> deleteClipEmbeddings(List<int> fileIDs) async {
final db = await instance.asyncDB;

View File

@@ -16,6 +16,8 @@ const mlVersionColumn = 'ml_version';
const personIdColumn = 'person_id';
const clusterIDColumn = 'cluster_id';
const personOrClusterIdColumn = 'person_or_cluster_id';
const textQueryColumn = 'text_query';
const createdAtColumn = 'created_at';
const createFacesTable = '''CREATE TABLE IF NOT EXISTS $facesTable (
$fileIDColumn INTEGER NOT NULL,
@@ -137,3 +139,18 @@ CREATE TABLE IF NOT EXISTS $faceCacheTable (
''';
const deleteFaceCacheTable = 'DELETE FROM $faceCacheTable';
// ## TEXT EMBEDDINGS CACHE TABLE
const textEmbeddingsCacheTable = 'text_embeddings_cache';
const createTextEmbeddingsCacheTable = '''
CREATE TABLE IF NOT EXISTS $textEmbeddingsCacheTable (
$textQueryColumn TEXT NOT NULL,
$embeddingColumn BLOB NOT NULL,
$mlVersionColumn INTEGER NOT NULL,
$createdAtColumn INTEGER NOT NULL,
PRIMARY KEY ($textQueryColumn)
);
''';
const deleteTextEmbeddingsCacheTable = 'DELETE FROM $textEmbeddingsCacheTable';

View File

@@ -183,7 +183,7 @@ class UploadLocksDB {
return "No lock found for $id";
}
final row = rows.first;
final time = row[_uploadLocksTable.columnTime] as int;
final time = int.tryParse(row[_uploadLocksTable.columnTime].toString()) ?? 0 ;
final owner = row[_uploadLocksTable.columnOwner] as String;
final duration = DateTime.now().millisecondsSinceEpoch - time;
return "Lock for $id acquired by $owner since ${Duration(milliseconds: duration)}";

View File

@@ -228,7 +228,7 @@ extension CustomColorScheme on ColorScheme {
Color get videoPlayerPrimaryColor => brightness == Brightness.light
? const Color.fromRGBO(0, 179, 60, 1)
: const Color.fromRGBO(1, 222, 77, 1);
Color get videoPlayerBorderColor => brightness == Brightness.light
? const Color(0xFF424242)
: const Color(0xFFFFFFFF);

View File

@@ -5,4 +5,4 @@ class CreateNewAlbumEvent extends Event {
final Collection collection;
CreateNewAlbumEvent(this.collection);
}
}

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