Compare commits
95 Commits
translatio
...
multiselec
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46db03c07b | ||
|
|
b877b0a4cc | ||
|
|
920b3b1931 | ||
|
|
ad95b5bd2d | ||
|
|
6b1757fc36 | ||
|
|
42527c0cd5 | ||
|
|
8810f88236 | ||
|
|
e1423f2030 | ||
|
|
c2ba7c56be | ||
|
|
93618117c5 | ||
|
|
08c38086a5 | ||
|
|
a4762d68f1 | ||
|
|
936c6f1b61 | ||
|
|
cfada04396 | ||
|
|
25287c64f5 | ||
|
|
168254ba42 | ||
|
|
05f7792012 | ||
|
|
d5f2b6456e | ||
|
|
ec6692b68a | ||
|
|
eead32ffe2 | ||
|
|
e90814c16e | ||
|
|
dbe0bbc9dc | ||
|
|
bbea022aef | ||
|
|
92c4b325ca | ||
|
|
bc66c1519a | ||
|
|
1e804d4829 | ||
|
|
3a8c95123e | ||
|
|
54ad3e4abb | ||
|
|
8e29a9e26b | ||
|
|
c82b829fe3 | ||
|
|
1dbdb270b4 | ||
|
|
1d1efc286f | ||
|
|
dc500795a1 | ||
|
|
11afcd92af | ||
|
|
f20c8caff0 | ||
|
|
c691b545a2 | ||
|
|
edcec3277e | ||
|
|
cda3a5b149 | ||
|
|
cc769fdd5b | ||
|
|
b74fe86e87 | ||
|
|
074f68146f | ||
|
|
e420d7b86f | ||
|
|
68caa3f7c6 | ||
|
|
5e5d5f4aad | ||
|
|
8713dd0707 | ||
|
|
102313f686 | ||
|
|
7ef9fdcaaa | ||
|
|
d902733809 | ||
|
|
0ef990de5a | ||
|
|
7722c4e16b | ||
|
|
6f5fdfb7b7 | ||
|
|
135124a487 | ||
|
|
d3c53794cf | ||
|
|
270cee8b09 | ||
|
|
9b05cc8c23 | ||
|
|
5b6c3e1b6e | ||
|
|
636793d5b1 | ||
|
|
700e52d11a | ||
|
|
82c7d1865c | ||
|
|
f08ee15cea | ||
|
|
901bfc945e | ||
|
|
6c25b094be | ||
|
|
3026ec5be3 | ||
|
|
4f5af8dcfa | ||
|
|
8079d44c68 | ||
|
|
575314c8a1 | ||
|
|
2684f9ce11 | ||
|
|
cd5582219c | ||
|
|
69332c78ad | ||
|
|
cba30e386d | ||
|
|
7663e76deb | ||
|
|
697d6f854d | ||
|
|
7aadb54ef1 | ||
|
|
2a2443efea | ||
|
|
d2bc2627a3 | ||
|
|
b1971810fb | ||
|
|
bd25af2b4b | ||
|
|
833b4656fe | ||
|
|
315c4ae6b7 | ||
|
|
49d9b3c928 | ||
|
|
440bafa56a | ||
|
|
87c236b629 | ||
|
|
fe732f2778 | ||
|
|
ca8a067966 | ||
|
|
5e3a779925 | ||
|
|
d1b06abada | ||
|
|
9e70dc4312 | ||
|
|
541d71f65c | ||
|
|
d8fc369a21 | ||
|
|
8efbebe9c4 | ||
|
|
a7300b7ac7 | ||
|
|
9224cea96f | ||
|
|
9fbc618d69 | ||
|
|
4614428f76 | ||
|
|
6fde4ee45f |
4
.github/workflows/web-deploy-one.yml
vendored
@@ -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 }}
|
||||
|
||||
4
.github/workflows/web-deploy-preview.yml
vendored
@@ -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 }}
|
||||
|
||||
3
.github/workflows/web-deploy-staging.yml
vendored
@@ -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
|
||||
|
||||
12
.github/workflows/web-deploy.yml
vendored
@@ -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
|
||||
|
||||
12
.github/workflows/web-lint.yml
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
167
infra/experiments/logs-viewer/README.md
Normal 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
|
||||
110
infra/experiments/logs-viewer/ente-theme.css
Normal 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);
|
||||
}
|
||||
}
|
||||
218
infra/experiments/logs-viewer/index.html
Normal 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>
|
||||
1146
infra/experiments/logs-viewer/script.js
Normal file
1065
infra/experiments/logs-viewer/styles.css
Normal file
1
mobile/apps/auth/.gitignore
vendored
@@ -8,6 +8,7 @@
|
||||
.buildlog/
|
||||
.history
|
||||
.svn/
|
||||
android/app/build/
|
||||
|
||||
# Editors
|
||||
.vscode/
|
||||
|
||||
@@ -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?"
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
155
mobile/apps/auth/lib/services/local_backup_service.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
243
mobile/apps/auth/lib/ui/home/add_tag_sheet.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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> {
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -414,6 +414,7 @@ Future<dynamic> showTextInputDialog(
|
||||
bool alwaysShowSuccessState = false,
|
||||
bool isPasswordInput = false,
|
||||
bool useRootNavigator = false,
|
||||
VoidCallback? onCancel,
|
||||
}) {
|
||||
return showDialog(
|
||||
barrierColor: backdropFaintDark,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
BIN
mobile/apps/locker/assets/2.0x/loading_photos_background.png
Normal file
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 39 KiB |
BIN
mobile/apps/locker/assets/3.0x/loading_photos_background.png
Normal file
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 63 KiB |
20
mobile/apps/locker/assets/icons/legacy-dark.svg
Normal file
@@ -0,0 +1,20 @@
|
||||
<svg width="200" height="200" viewBox="0 0 200 200" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_34053_111310)">
|
||||
<path d="M170 66.7593C170 71.8084 169.042 76.732 167.151 81.3933C165.192 86.2233 162.316 90.5536 158.606 94.265L147.36 105.511L151.657 109.806C152.962 111.112 152.26 113.347 150.441 113.671L128.349 117.598C126.785 117.875 125.423 116.513 125.7 114.949L129.628 92.8595C129.952 91.0413 132.187 90.3393 133.492 91.6442L137.12 95.2717L148.366 84.0261C152.978 79.4144 155.519 73.282 155.519 66.7609C155.519 60.2383 152.978 54.105 148.365 49.4941C143.753 44.8815 137.621 42.3422 131.097 42.3422C124.574 42.3422 118.442 44.8815 113.83 49.4941L107.387 55.936C107.383 55.9064 107.378 55.8784 107.373 55.8488L104.299 38.5628C107.848 35.1847 111.936 32.5447 116.463 30.7097C121.123 28.818 126.048 27.8594 131.097 27.8594C136.146 27.8594 141.07 28.818 145.732 30.7089C150.564 32.667 154.894 35.5421 158.606 39.2528C162.317 42.9643 165.192 47.2946 167.151 52.1246C169.041 56.7859 169.999 61.711 170 66.7593Z" fill="#4AAF3C"/>
|
||||
<path d="M133.814 119.087L105.12 147.779C103.762 149.137 101.92 149.9 99.9998 149.9C98.0791 149.9 96.2375 149.137 94.879 147.779L70.7775 123.678L67.503 126.953C66.1972 128.259 63.9623 127.556 63.6392 125.737L59.7107 103.646C59.4332 102.083 60.7966 100.72 62.3598 100.997L84.4519 104.925C86.2702 105.249 86.9731 107.483 85.6673 108.789L81.0183 113.438L100.001 132.42L124.507 107.914L123.318 114.601C123.053 116.096 123.535 117.63 124.609 118.703C125.684 119.778 127.217 120.259 128.712 119.994L133.814 119.087H133.814Z" fill="#4AAF3C"/>
|
||||
<path d="M102.359 58.8607L80.2668 54.9325C78.4485 54.6095 77.7456 52.3748 79.0514 51.0692L83.1799 46.9412C79.0498 43.9533 74.1009 42.3406 68.9034 42.3406C62.3808 42.3406 56.2477 44.88 51.6363 49.4925C47.0232 54.1042 44.4828 60.2359 44.4828 66.7585C44.4828 73.2812 47.0232 79.4128 51.6363 84.0246L66.9435 99.3301L62.7415 98.5834C61.2462 98.3179 59.7125 98.8 58.6386 99.8738C57.5647 100.948 57.0825 102.482 57.348 103.977L58.697 111.564L41.3955 94.2635C37.6836 90.552 34.8089 86.2217 32.8499 81.3917C30.9588 76.7312 30 71.8076 30 66.7593C30 61.711 30.9588 56.7867 32.8491 52.1254C34.8081 47.2946 37.6836 42.9643 41.3947 39.2536C45.1057 35.5421 49.4365 32.6678 54.267 30.7097C58.9296 28.818 63.8537 27.8594 68.9034 27.8594C73.953 27.8594 78.8771 28.818 83.5389 30.7081C87.158 32.1753 90.4956 34.1565 93.503 36.6183L97.2157 32.9061C98.5215 31.6004 100.756 32.3032 101.079 34.1214L105.007 56.211C105.286 57.7741 103.922 59.1381 102.359 58.8607Z" fill="#4AAF3C"/>
|
||||
</g>
|
||||
<g filter="url(#filter0_f_34053_111310)">
|
||||
<ellipse cx="102.576" cy="170.536" rx="27.5758" ry="1.59085" fill="#1CA609" fill-opacity="0.5"/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_f_34053_111310" x="70.5" y="164.445" width="64.1515" height="12.1797" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="2.25" result="effect1_foregroundBlur_34053_111310"/>
|
||||
</filter>
|
||||
<clipPath id="clip0_34053_111310">
|
||||
<rect width="140" height="122" fill="white" transform="translate(30 27.8828)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.2 KiB |
20
mobile/apps/locker/assets/icons/legacy-light.svg
Normal file
@@ -0,0 +1,20 @@
|
||||
<svg width="200" height="200" viewBox="0 0 200 200" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_34053_111286)">
|
||||
<path d="M170 66.7593C170 71.8084 169.042 76.732 167.151 81.3933C165.192 86.2233 162.316 90.5536 158.606 94.265L147.36 105.511L151.657 109.806C152.962 111.112 152.26 113.347 150.441 113.671L128.349 117.598C126.785 117.875 125.423 116.513 125.7 114.949L129.628 92.8594C129.952 91.0413 132.187 90.3393 133.492 91.6441L137.12 95.2717L148.366 84.0261C152.978 79.4144 155.519 73.2819 155.519 66.7609C155.519 60.2383 152.978 54.105 148.365 49.4941C143.753 44.8815 137.621 42.3422 131.097 42.3422C124.574 42.3422 118.442 44.8815 113.83 49.4941L107.387 55.936C107.383 55.9064 107.378 55.8784 107.373 55.8488L104.299 38.5628C107.848 35.1847 111.936 32.5447 116.463 30.7097C121.123 28.818 126.048 27.8594 131.097 27.8594C136.146 27.8594 141.07 28.818 145.732 30.7089C150.564 32.667 154.894 35.5421 158.606 39.2528C162.317 42.9643 165.192 47.2946 167.151 52.1246C169.041 56.7859 169.999 61.7102 170 66.7585V66.7593Z" fill="#4AAF3C"/>
|
||||
<path d="M133.814 119.087L105.12 147.779C103.762 149.137 101.92 149.9 99.9998 149.9C98.0791 149.9 96.2375 149.137 94.879 147.779L70.7775 123.678L67.503 126.953C66.1972 128.259 63.9623 127.556 63.6392 125.737L59.7107 103.646C59.4332 102.083 60.7966 100.72 62.3598 100.997L84.4519 104.925C86.2702 105.249 86.9731 107.483 85.6673 108.789L81.0183 113.438L100.001 132.42L124.507 107.914L123.318 114.601C123.053 116.096 123.535 117.63 124.609 118.703C125.684 119.778 127.217 120.259 128.712 119.994L133.814 119.087H133.814Z" fill="#4AAF3C"/>
|
||||
<path d="M102.359 58.8607L80.2668 54.9325C78.4485 54.6095 77.7456 52.3748 79.0514 51.0692L83.1799 46.9412C79.0498 43.9533 74.1009 42.3406 68.9034 42.3406C62.3808 42.3406 56.2477 44.88 51.6363 49.4925C47.0232 54.1042 44.4828 60.2359 44.4828 66.7585C44.4828 73.2812 47.0232 79.4128 51.6363 84.0246L66.9435 99.3301L62.7415 98.5834C61.2462 98.3179 59.7125 98.8 58.6386 99.8738C57.5647 100.948 57.0825 102.482 57.348 103.977L58.697 111.564L41.3955 94.2635C37.6836 90.552 34.8089 86.2217 32.8499 81.3917C30.9588 76.7312 30 71.8076 30 66.7593C30 61.711 30.9588 56.7867 32.8491 52.1254C34.8081 47.2946 37.6836 42.9643 41.3947 39.2536C45.1057 35.5421 49.4365 32.6678 54.267 30.7097C58.9296 28.818 63.8537 27.8594 68.9034 27.8594C73.953 27.8594 78.8771 28.818 83.5389 30.7081C87.158 32.1753 90.4957 34.1565 93.503 36.6183L97.2157 32.9061C98.5215 31.6004 100.756 32.3032 101.079 34.1214L105.007 56.211C105.286 57.7741 103.922 59.1373 102.359 58.8599V58.8607Z" fill="#4AAF3C"/>
|
||||
</g>
|
||||
<g filter="url(#filter0_f_34053_111286)">
|
||||
<ellipse cx="101.576" cy="170.536" rx="27.5758" ry="1.59085" fill="#E1E1E1" fill-opacity="0.5"/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_f_34053_111286" x="69.5" y="164.445" width="64.1515" height="12.1797" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="2.25" result="effect1_foregroundBlur_34053_111286"/>
|
||||
</filter>
|
||||
<clipPath id="clip0_34053_111286">
|
||||
<rect width="140" height="122" fill="white" transform="translate(30 27.8828)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.2 KiB |
BIN
mobile/apps/locker/assets/loading_photos_background.png
Normal file
|
After Width: | Height: | Size: 68 KiB |
BIN
mobile/apps/locker/assets/loading_photos_background_dark.png
Normal file
|
After Width: | Height: | Size: 63 KiB |
@@ -83,6 +83,7 @@ flutter:
|
||||
|
||||
assets:
|
||||
- assets/
|
||||
- assets/icons/
|
||||
|
||||
fonts:
|
||||
- family: Inter
|
||||
|
||||
1
mobile/apps/photos/AGENTS.md
Symbolic link
@@ -0,0 +1 @@
|
||||
CLAUDE.md
|
||||
@@ -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
|
||||
|
||||
@@ -1 +1 @@
|
||||
ఫోటోలు,ఫోటోగ్రఫీ,కుటుంబం,గోప్యత,క్లౌడ్,బ్యాకప్,వీడియోలు,ఫోటో,సంకేతీకరణ,నిల్వ,ఆల్బమ్,ప్రత్యామ్నాయం
|
||||
photos,photography,family,privacy,cloud,backup,videos,photo,encryption,storage,album,alternative
|
||||
|
||||
@@ -1 +1 @@
|
||||
ఎంటే ఫోటోలు
|
||||
Ente Photos
|
||||
|
||||
@@ -1 +1 @@
|
||||
సంకేతీకరించిన ఫోటో నిల్వ
|
||||
Encrypted photo storage
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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)}";
|
||||
|
||||
@@ -228,7 +228,7 @@ extension CustomColorScheme on ColorScheme {
|
||||
Color get videoPlayerPrimaryColor => brightness == Brightness.light
|
||||
? const Color.fromRGBO(0, 179, 60, 1)
|
||||
: const Color.fromRGBO(1, 222, 77, 1);
|
||||
|
||||
|
||||
Color get videoPlayerBorderColor => brightness == Brightness.light
|
||||
? const Color(0xFF424242)
|
||||
: const Color(0xFFFFFFFF);
|
||||
|
||||
@@ -5,4 +5,4 @@ class CreateNewAlbumEvent extends Event {
|
||||
final Collection collection;
|
||||
|
||||
CreateNewAlbumEvent(this.collection);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1816,4 +1816,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1823,8 +1823,6 @@
|
||||
"videosProcessed": "Zpracovaná videa",
|
||||
"totalVideos": "Celkový počet videí",
|
||||
"skippedVideos": "Přeskočená videa",
|
||||
"videoStreamingDescriptionLine1": "Přehrávejte videa okamžitě na jakémkoli zařízení.",
|
||||
"videoStreamingDescriptionLine2": "Povolte pro zpracování video streamů na tomto zařízení.",
|
||||
"videoStreamingNote": "Na tomto zařízení se zpracovávají pouze videa z posledních 60 dnů, která jsou kratší než 1 minuta. U starších/delších videí povolte streamování v desktopové aplikaci.",
|
||||
"createStream": "Vytvořit stream",
|
||||
"recreateStream": "Obnovit stream",
|
||||
@@ -1907,29 +1905,12 @@
|
||||
"deleteFiles": "Smazat soubory",
|
||||
"areYouSureDeleteFiles": "Opravdu chcete tyto soubory smazat?",
|
||||
"greatJob": "Dobrá práce!",
|
||||
"cleanedUpSimilarImages": "Uvolnili jste {size} místa",
|
||||
"@cleanedUpSimilarImages": {
|
||||
"placeholders": {
|
||||
"size": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"size": "Velikost",
|
||||
"similarity": "Podobnost",
|
||||
"analyzingPhotosLocally": "Analyzuji vaše fotografie lokálně...",
|
||||
"lookingForVisualSimilarities": "Hledání vizuálních podobností...",
|
||||
"comparingImageDetails": "Porovnávání detailů obrázku...",
|
||||
"findingSimilarImages": "Hledání podobných obrázků...",
|
||||
"almostDone": "Téměř hotovo...",
|
||||
"processingLocally": "Místní zpracování",
|
||||
"useMLToFindSimilarImages": "Zkontrolujte a odstraňte obrázky, které se navzájem podobají.",
|
||||
"all": "Vše",
|
||||
"similar": "Podobné",
|
||||
"identical": "Identické",
|
||||
"nothingHereTryAnotherFilter": "Tady nic není, zkuste jiný filtr! 👀",
|
||||
"related": "Související",
|
||||
"hoorayyyy": "Huráááá!",
|
||||
"nothingToTidyUpHere": "Tady není co uklízet",
|
||||
"deletingDash": "Mazání - "
|
||||
}
|
||||
"nothingHereTryAnotherFilter": "Tady nic není, zkuste jiný filtr! 👀"
|
||||
}
|
||||
|
||||
@@ -1917,11 +1917,6 @@
|
||||
},
|
||||
"size": "Größe",
|
||||
"similarity": "Ähnlichkeit",
|
||||
"analyzingPhotosLocally": "Analysiere deine Fotos lokal...",
|
||||
"lookingForVisualSimilarities": "Suche nach visuellen Ähnlichkeiten...",
|
||||
"comparingImageDetails": "Vergleiche Bilddetails...",
|
||||
"findingSimilarImages": "Finde ähnliche Bilder...",
|
||||
"almostDone": "Fast fertig...",
|
||||
"processingLocally": "Lokale Verarbeitung",
|
||||
"useMLToFindSimilarImages": "Überprüfe und entferne Bilder, die sich ähnlich sehen.",
|
||||
"all": "Alle",
|
||||
@@ -1931,11 +1926,10 @@
|
||||
"related": "Verwandt",
|
||||
"hoorayyyy": "Hurraaaa!",
|
||||
"nothingToTidyUpHere": "Hier gibt es nichts zu bereinigen",
|
||||
"deletingDash": "Lösche - ",
|
||||
"cLTitle1": "Ähnliche Bilder",
|
||||
"cLDesc1": "Wir führen ein neues ML-basiertes System ein, um ähnliche Bilder zu erkennen, mit dem du deine Bibliothek bereinigen kannst. Verfügbar unter Einstellungen -> Backup -> Speicherplatz freigeben",
|
||||
"cLTitle2": "Verbesserungen für Video-Streaming",
|
||||
"cLDesc2": "Du kannst jetzt die Erstellung von Videos manuell direkt aus der App auslösen. Wir haben auch einen neuen Einstellungsbildschirm für das Video-Streaming hinzugefügt, der dir zeigen wird, welcher Prozentsatz deiner Videos für Streaming verarbeitet wurde",
|
||||
"cLDesc1": "Wir führen ein neues ML-basiertes System ein, um ähnliche Bilder zu erkennen, mit dem du deine Bibliothek bereinigen kannst. Verfügbar unter Einstellungen -> Sicherung -> Speicherplatz freigeben",
|
||||
"cLTitle2": "Video-Streaming-Verbesserungen",
|
||||
"cLDesc2": "Du kannst jetzt die Stream-Generierung für Videos direkt aus der App manuell auslösen. Wir haben auch einen neuen Video-Streaming-Einstellungsbildschirm hinzugefügt, der dir zeigt, welcher Prozentsatz deiner Videos für das Streaming verarbeitet wurde",
|
||||
"cLTitle3": "Leistungsverbesserungen",
|
||||
"cLDesc3": "Zahlreiche Verbesserungen unter der Haube, u.a. bessere Nutzung des Cache und ein sanfteres Scroll-Erlebnis"
|
||||
"cLDesc3": "Mehrere Verbesserungen im Hintergrund, einschließlich besserer Cache-Nutzung und einer flüssigeren Scroll-Erfahrung"
|
||||
}
|
||||
@@ -1917,11 +1917,6 @@
|
||||
},
|
||||
"size": "Tamaño",
|
||||
"similarity": "Similitud",
|
||||
"analyzingPhotosLocally": "Analizando tus fotos localmente...",
|
||||
"lookingForVisualSimilarities": "Buscando similitudes visuales...",
|
||||
"comparingImageDetails": "Comparando detalles de imágenes...",
|
||||
"findingSimilarImages": "Buscando imágenes similares...",
|
||||
"almostDone": "Ya casi terminamos...",
|
||||
"processingLocally": "Procesando localmente",
|
||||
"useMLToFindSimilarImages": "Revisar y eliminar imágenes que se parecen entre sí.",
|
||||
"all": "Todas",
|
||||
@@ -1931,11 +1926,10 @@
|
||||
"related": "Relacionado",
|
||||
"hoorayyyy": "¡Hurraaaa!",
|
||||
"nothingToTidyUpHere": "Nada que limpiar aquí",
|
||||
"deletingDash": "Eliminando - ",
|
||||
"cLTitle1": "Imágenes similares",
|
||||
"cLDesc1": "Estamos introduciendo un nuevo sistema basado en ML para detectar imágenes similares, utilizando el cual puede limpiar tu biblioteca. Disponible en Ajustes -> Copia de seguridad -> Liberar espacio",
|
||||
"cLTitle2": "Mejoras de transmisión de vídeo",
|
||||
"cLDesc2": "Ahora puedes activar manualmente la generación de transmisión de vídeos directamente desde la aplicación. También hemos añadido una nueva pantalla de configuración de transmisión de vídeo que te mostrará qué porcentaje de tus vídeos han sido procesados para la transmisión",
|
||||
"cLDesc1": "Estamos introduciendo un nuevo sistema basado en ML para detectar imágenes similares, con el cual puedes limpiar tu biblioteca. Disponible en Configuración -> Copia de seguridad -> Liberar espacio",
|
||||
"cLTitle2": "Mejoras de transmisión de video",
|
||||
"cLDesc2": "Ahora puedes activar manualmente la generación de transmisión para videos directamente desde la aplicación. También hemos agregado una nueva pantalla de configuración de transmisión de video que te mostrará qué porcentaje de tus videos han sido procesados para transmisión",
|
||||
"cLTitle3": "Mejoras de rendimiento",
|
||||
"cLDesc3": "Múltiples mejoras bajo la capa, incluyendo un mejor uso de caché y una experiencia de desplazamiento más suave"
|
||||
"cLDesc3": "Múltiples mejoras internas, incluyendo mejor uso de caché y una experiencia de desplazamiento más fluida"
|
||||
}
|
||||
@@ -1909,11 +1909,6 @@
|
||||
},
|
||||
"size": "Taille",
|
||||
"similarity": "Similitude",
|
||||
"analyzingPhotosLocally": "Analyse de vos photos en local ...",
|
||||
"lookingForVisualSimilarities": "Recherche de ressemblances visuelles...",
|
||||
"comparingImageDetails": "Comparaison des détails de l'image...",
|
||||
"findingSimilarImages": "Recherche d'images similaires...",
|
||||
"almostDone": "C'est presque terminé...",
|
||||
"processingLocally": "Traitement local",
|
||||
"useMLToFindSimilarImages": "Examinez et supprimez les images qui se ressemblent entre elles.",
|
||||
"all": "Toutes",
|
||||
@@ -1923,11 +1918,10 @@
|
||||
"related": "Liés",
|
||||
"hoorayyyy": "Houraaa !",
|
||||
"nothingToTidyUpHere": "Rien à nettoyer ici",
|
||||
"deletingDash": "Suppression - ",
|
||||
"cLTitle1": "Images similaires",
|
||||
"cLDesc1": "Nous introduisons un nouveau système basé sur le ML pour détecter des images similaires, en utilisant lesquelles vous pouvez nettoyer de votre bibliothèque. Disponible dans Paramètres->Sauvegarde->Espace libre",
|
||||
"cLTitle2": "Améliorations du streaming vidéo",
|
||||
"cLDesc2": "Vous pouvez maintenant déclencher manuellement la génération de flux vidéos directement depuis l'application. Nous avons également ajouté un nouvel écran de paramètres de streaming vidéo qui vous montrera quel pourcentage de vos vidéos ont été traitées pour le streaming",
|
||||
"cLTitle3": "Amélioration des performances",
|
||||
"cLDesc3": "Plusieurs améliorations invisibles, y compris une meilleure utilisation du cache et une expérience de défilement plus fluide"
|
||||
"cLDesc1": "Nous introduisons un nouveau système basé sur l'IA pour détecter les images similaires, avec lequel vous pouvez nettoyer votre bibliothèque. Disponible dans Paramètres -> Sauvegarde -> Libérer de l'espace",
|
||||
"cLTitle2": "Améliorations de la diffusion vidéo",
|
||||
"cLDesc2": "Vous pouvez maintenant déclencher manuellement la génération de flux pour les vidéos directement depuis l'application. Nous avons également ajouté un nouvel écran de paramètres de diffusion vidéo qui vous montrera quel pourcentage de vos vidéos ont été traitées pour la diffusion",
|
||||
"cLTitle3": "Améliorations des performances",
|
||||
"cLDesc3": "Plusieurs améliorations internes, incluant une meilleure utilisation du cache et une expérience de défilement plus fluide"
|
||||
}
|
||||
@@ -1745,5 +1745,11 @@
|
||||
"birthdayNotifications": "Notifiche dei compleanni",
|
||||
"receiveRemindersOnBirthdays": "Ricevi promemoria quando è il compleanno di qualcuno. Toccare la notifica ti porterà alle foto della persona che compie gli anni.",
|
||||
"happyBirthday": "Buon compleanno! 🥳",
|
||||
"birthdays": "Compleanni"
|
||||
"birthdays": "Compleanni",
|
||||
"cLTitle1": "Immagini simili",
|
||||
"cLDesc1": "Stiamo introducendo un nuovo sistema basato su ML per rilevare immagini simili, con il quale puoi pulire la tua libreria. Disponibile in Impostazioni -> Backup -> Libera spazio",
|
||||
"cLTitle2": "Miglioramenti streaming video",
|
||||
"cLDesc2": "Ora puoi attivare manualmente la generazione di stream per i video direttamente dall'app. Abbiamo anche aggiunto una nuova schermata delle impostazioni di streaming video che ti mostrerà quale percentuale dei tuoi video è stata elaborata per lo streaming",
|
||||
"cLTitle3": "Miglioramenti delle prestazioni",
|
||||
"cLDesc3": "Multipli miglioramenti interni, incluso un miglior utilizzo della cache e un'esperienza di scorrimento più fluida"
|
||||
}
|
||||
@@ -1665,5 +1665,11 @@
|
||||
"moon": "月明かりの中",
|
||||
"onTheRoad": "再び道で",
|
||||
"food": "料理を楽しむ",
|
||||
"pets": "毛むくじゃらな仲間たち"
|
||||
"pets": "毛むくじゃらな仲間たち",
|
||||
"cLTitle1": "類似画像",
|
||||
"cLDesc1": "類似画像を検出する新しいML基盤システムを導入し、ライブラリをクリーンアップできます。設定 -> バックアップ -> 容量を空ける で利用可能",
|
||||
"cLTitle2": "動画ストリーミングの強化",
|
||||
"cLDesc2": "アプリから直接、動画のストリーム生成を手動でトリガーできるようになりました。また、動画のうち何パーセントがストリーミング用に処理されたかを表示する新しい動画ストリーミング設定画面も追加しました",
|
||||
"cLTitle3": "パフォーマンスの改善",
|
||||
"cLDesc3": "より良いキャッシュ使用とよりスムーズなスクロール体験を含む、複数の内部改善"
|
||||
}
|
||||
@@ -1811,55 +1811,15 @@
|
||||
"font": "Šriftas",
|
||||
"background": "Fonas",
|
||||
"align": "Lygiuoti",
|
||||
"videosProcessed": "Vaizdo įrašai apdoroti",
|
||||
"totalVideos": "Iš viso vaizdo įrašų",
|
||||
"skippedVideos": "Praleisti vaizdo įrašai",
|
||||
"similarImages": "Panašūs vaizdai",
|
||||
"findSimilarImages": "Rasti panašų vaizdų",
|
||||
"noSimilarImagesFound": "Panašių vaizdų nerasta",
|
||||
"yourPhotosLookUnique": "Jūsų nuotraukos atrodo ypatingos",
|
||||
"selectionOptions": "Pasirinkimo parinktys",
|
||||
"selectAllWithCount": "Visi panašumai ({count})",
|
||||
"@selectAllWithCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"chooseSimilarImagesToSelect": "Pasirinkite vaizdus pagal jų vizualinį panašumą.",
|
||||
"deleteFiles": "Ištrinti failus",
|
||||
"areYouSureDeleteFiles": "Ar tikrai norite ištrinti šiuos failus?",
|
||||
"greatJob": "Puiku!",
|
||||
"cleanedUpSimilarImages": "Atlaisvinote {size} vietos.",
|
||||
"@cleanedUpSimilarImages": {
|
||||
"placeholders": {
|
||||
"size": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"size": "Dydis",
|
||||
"similarity": "Panašumas",
|
||||
"analyzingPhotosLocally": "Analizuojamos jūsų nuotraukos vietoje...",
|
||||
"lookingForVisualSimilarities": "Ieškoma vizualių panašumų...",
|
||||
"comparingImageDetails": "Lyginama vaizdo išsami informacija...",
|
||||
"findingSimilarImages": "Randama panašių vaizdų...",
|
||||
"almostDone": "Beveik baigta...",
|
||||
"processingLocally": "Apdorojama vietoje",
|
||||
"useMLToFindSimilarImages": "Peržiūrėkite ir pašalinkite vaizdus, kurie atrodo panašūs vienas į kitą.",
|
||||
"all": "Visi",
|
||||
"similar": "Panašūs",
|
||||
"identical": "Vienodi",
|
||||
"nothingHereTryAnotherFilter": "Čia nieko nėra. Bandykite kitą filtrą. 👀",
|
||||
"related": "Susiję",
|
||||
"hoorayyyy": "Puiiiiku!",
|
||||
"nothingToTidyUpHere": "Čia nėra ko tvarkyti.",
|
||||
"deletingDash": "Ištrinama - ",
|
||||
"cLTitle1": "Panašūs vaizdai",
|
||||
"cLDesc1": "Pristatome naują MM sistemą, skirtą aptikti panašius vaizdus, kurią naudojant galite išvalyti savo biblioteką. Pasiekiama Nustatymuose -> Atsarginė kopija -> Atlaisvinti vietą.",
|
||||
"cLTitle2": "Vaizdo įrašų srautinio perdavimo patobulinimai",
|
||||
"cLDesc2": "Dabar galite rankiniu būdu paleisti vaizdo įrašų srauto perdavimo generavimą tiesiai iš programos. Taip pat pridėjome naują vaizdo įrašų srauto perdavimo nustatymų ekraną, kuriame bus rodomas procentinis vaizdo įrašų skaičius, apdorotų srauto perdavimui.",
|
||||
"cLTitle3": "Našumo patobulinimai",
|
||||
"cLDesc3": "Daugybė patobulinimų, įskaitant geresnį podėlio naudojimą ir sklandesnį slinkimo patirtį."
|
||||
}
|
||||
"processingLocally": "Apdorojama vietoje"
|
||||
}
|
||||
|
||||
@@ -1772,5 +1772,11 @@
|
||||
"thePersonWillNotBeDisplayed": "De persoon wordt niet meer getoond in de personen sectie. Foto's blijven ongemoeid.",
|
||||
"areYouSureYouWantToMergeThem": "Weet je zeker dat je ze wilt samenvoegen?",
|
||||
"allUnnamedGroupsWillBeMergedIntoTheSelectedPerson": "Alle naamloze groepen worden samengevoegd met de geselecteerde persoon. Dit kan nog steeds ongedaan worden gemaakt vanuit het geschiedenisoverzicht van de persoon.",
|
||||
"yesIgnore": "Ja, negeer"
|
||||
"yesIgnore": "Ja, negeer",
|
||||
"cLTitle1": "Vergelijkbare afbeeldingen",
|
||||
"cLDesc1": "We introduceren een nieuw ML-gebaseerd systeem om vergelijkbare afbeeldingen te detecteren, waarmee je je bibliotheek kunt opschonen. Beschikbaar in Instellingen -> Backup -> Ruimte vrijmaken",
|
||||
"cLTitle2": "Video streaming verbeteringen",
|
||||
"cLDesc2": "Je kunt nu handmatig stream generatie voor video's activeren direct vanuit de app. We hebben ook een nieuw video streaming instellingenscherm toegevoegd dat toont welk percentage van je video's is verwerkt voor streaming",
|
||||
"cLTitle3": "Prestatieverbeteringen",
|
||||
"cLDesc3": "Meerdere verbeteringen onder de motorkap, inclusief beter cache gebruik en een vloeiendere scroll ervaring"
|
||||
}
|
||||
@@ -1736,5 +1736,11 @@
|
||||
"albumsWidgetDesc": "Velg albumene du ønsker å se på din hjemskjerm.",
|
||||
"memoriesWidgetDesc": "Velg typen minner du ønsker å se på din hjemskjerm.",
|
||||
"smartMemories": "Smarte minner",
|
||||
"pastYearsMemories": "Tidligere års minner"
|
||||
"pastYearsMemories": "Tidligere års minner",
|
||||
"cLTitle1": "Lignende bilder",
|
||||
"cLDesc1": "Vi introduserer et nytt ML-basert system for å oppdage lignende bilder, som du kan bruke til å rydde opp i biblioteket ditt. Tilgjengelig i Innstillinger -> Sikkerhetskopi -> Frigjør plass",
|
||||
"cLTitle2": "Video streaming forbedringer",
|
||||
"cLDesc2": "Du kan nå manuelt utløse stream generering for videoer direkte fra appen. Vi har også lagt til en ny video streaming innstillinger skjerm som viser deg hvor mange prosent av videoene dine som er behandlet for streaming",
|
||||
"cLTitle3": "Ytelsesforbedringer",
|
||||
"cLDesc3": "Flere forbedringer under panseret, inkludert bedre cache bruk og en jevnere rullingsopplevelse"
|
||||
}
|
||||
@@ -1819,5 +1819,11 @@
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"cLTitle1": "Podobne obrazy",
|
||||
"cLDesc1": "Wprowadzamy nowy system oparty na ML do wykrywania podobnych obrazów, za pomocą którego możesz posprzątać swoją bibliotekę. Dostępne w Ustawienia->Kopia zapasowa->Zwolnij miejsce",
|
||||
"cLTitle2": "Ulepszenia streamingu wideo",
|
||||
"cLDesc2": "Możesz teraz ręcznie wyzwolić generowanie strumienia dla filmów bezpośrednio z aplikacji. Dodaliśmy również nowy ekran ustawień streamingu wideo, który pokaże ci, jaki procent twoich filmów zostało przetworzonych do streamingu",
|
||||
"cLTitle3": "Ulepszenia wydajności",
|
||||
"cLDesc3": "Liczne ulepszenia pod maską, w tym lepsze wykorzystanie pamięci podręcznej i płynniejsze przewijanie"
|
||||
}
|
||||
@@ -1819,5 +1819,11 @@
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"cLTitle1": "Imagens similares",
|
||||
"cLDesc1": "Estamos introduzindo um novo sistema baseado em ML para detectar imagens similares, com o qual você pode limpar sua biblioteca. Disponível em Configurações -> Backup -> Liberar espaço",
|
||||
"cLTitle2": "Melhorias do streaming de vídeo",
|
||||
"cLDesc2": "Agora você pode acionar manualmente a geração de stream para vídeos diretamente do aplicativo. Também adicionamos uma nova tela de configurações de streaming de vídeo que mostrará qual porcentagem dos seus vídeos foram processados para streaming",
|
||||
"cLTitle3": "Melhorias de desempenho",
|
||||
"cLDesc3": "Múltiplas melhorias internas, incluindo melhor uso de cache e uma experiência de rolagem mais suave"
|
||||
}
|
||||
|
||||
@@ -1819,5 +1819,11 @@
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"cLTitle1": "Imagens similares",
|
||||
"cLDesc1": "Estamos a introduzir um novo sistema baseado em ML para detectar imagens similares, com o qual pode limpar a sua biblioteca. Disponível em Definições -> Cópia de segurança -> Libertar espaço",
|
||||
"cLTitle2": "Melhorias do streaming de vídeo",
|
||||
"cLDesc2": "Agora pode accionar manualmente a geração de stream para vídeos directamente da aplicação. Também adicionámos um novo ecrã de definições de streaming de vídeo que mostrará que percentagem dos seus vídeos foram processados para streaming",
|
||||
"cLTitle3": "Melhorias de desempenho",
|
||||
"cLDesc3": "Múltiplas melhorias internas, incluindo melhor uso de cache e uma experiência de deslocação mais suave"
|
||||
}
|
||||
|
||||
@@ -1521,5 +1521,11 @@
|
||||
"joinAlbum": "Alăturați-vă albumului",
|
||||
"joinAlbumSubtext": "pentru a vedea și a adăuga fotografii",
|
||||
"joinAlbumSubtextViewer": "pentru a adăuga la albumele distribuite",
|
||||
"join": "Alăturare"
|
||||
"join": "Alăturare",
|
||||
"cLTitle1": "Imagini similare",
|
||||
"cLDesc1": "Introducem un nou sistem bazat pe ML pentru detectarea imaginilor similare, cu care vă puteți curăța biblioteca. Disponibil în Setări->Backup->Eliberați Spațiu",
|
||||
"cLTitle2": "Îmbunătățiri streaming video",
|
||||
"cLDesc2": "Acum puteți declanșa manual generarea fluxului pentru videoclipuri direct din aplicație. Am adăugat, de asemenea, un nou ecran de setări pentru streaming video care vă va arăta ce procent din videoclipurile dvs. au fost procesate pentru streaming",
|
||||
"cLTitle3": "Îmbunătățiri de Performanță",
|
||||
"cLDesc3": "Multiple îmbunătățiri în fundal, inclusiv o utilizare mai bună a cache-ului și o experiență de defilare mai fluidă"
|
||||
}
|
||||
@@ -1777,19 +1777,19 @@
|
||||
"different": "Разные",
|
||||
"sameperson": "Тот же человек?",
|
||||
"indexingPausedStatusDescription": "Индексирование приостановлено. Оно автоматически возобновится, когда устройство будет готово. Устройство считается готовым, когда уровень заряда батареи, её состояние и температура находятся в пределах нормы.",
|
||||
"thisWeek": "На этой неделе",
|
||||
"lastWeek": "На прошлой неделе",
|
||||
"thisMonth": "В этом месяце",
|
||||
"thisYear": "В этом году",
|
||||
"groupBy": "Группировать по",
|
||||
"faceThumbnailGenerationFailed": "Не удалось создать миниатюры лиц",
|
||||
"fileAnalysisFailed": "Не удалось проанализировать файл",
|
||||
"addingPhotos": "Добавление фото",
|
||||
"gettingReady": "Идет подготовка",
|
||||
"addSomePhotosDesc2": "знакомые лица",
|
||||
"analysis": "Анализ",
|
||||
"day": "Дню",
|
||||
"day": "День",
|
||||
"filter": "Фильтр",
|
||||
"font": "Шрифт",
|
||||
"skippedVideos": "Пропущенные видео"
|
||||
"cLTitle1": "Похожие изображения",
|
||||
"cLDesc1": "Мы внедряем новую систему на основе ML для обнаружения похожих изображений, с помощью которой вы можете очистить свою библиотеку. Доступно в Настройки->Резервная копия->Освободить место",
|
||||
"cLTitle2": "Улучшения видео стриминга",
|
||||
"cLDesc2": "Теперь вы можете вручную запустить генерацию потока для видео прямо из приложения. Мы также добавили новый экран настроек видео стриминга, который покажет вам, какой процент ваших видео был обработан для стриминга",
|
||||
"cLTitle3": "Улучшения производительности",
|
||||
"cLDesc3": "Множественные улучшения под капотом, включая лучшее использование кэша и более плавную прокрутку"
|
||||
}
|
||||
@@ -1776,5 +1776,11 @@
|
||||
"same": "Aynı",
|
||||
"different": "Farklı",
|
||||
"sameperson": "Aynı kişi mi?",
|
||||
"indexingPausedStatusDescription": "Dizin oluşturma duraklatıldı. Cihaz hazır olduğunda otomatik olarak devam edecektir. Cihaz, pil seviyesi, pil sağlığı ve termal durumu sağlıklı bir aralıkta olduğunda hazır kabul edilir."
|
||||
"indexingPausedStatusDescription": "Dizin oluşturma duraklatıldı. Cihaz hazır olduğunda otomatik olarak devam edecektir. Cihaz, pil seviyesi, pil sağlığı ve termal durumu sağlıklı bir aralıkta olduğunda hazır kabul edilir.",
|
||||
"cLTitle1": "Benzer görüntüler",
|
||||
"cLDesc1": "Benzer görüntüleri tespit etmek için yeni bir ML tabanlı sistem tanıtıyoruz, bununla kütüphanenizi temizleyebilirsiniz. Ayarlar -> Yedekleme -> Alan boşalt kısmından ulaşabilirsiniz",
|
||||
"cLTitle2": "Video akış geliştirmeleri",
|
||||
"cLDesc2": "Artık doğrudan uygulamadan videolar için akış oluşturmayı manuel olarak tetikleyebilirsiniz. Ayrıca videolarınızın yüzde kaçının akış için işlendiğini gösteren yeni bir video akış ayarları ekranı da ekledik",
|
||||
"cLTitle3": "Performans İyileştirmeleri",
|
||||
"cLDesc3": "Daha iyi önbellek kullanımı ve daha pürüzsüz kaydırma deneyimi dahil olmak üzere perde arkasında birçok iyileştirme"
|
||||
}
|
||||
@@ -1509,5 +1509,11 @@
|
||||
},
|
||||
"legacyInvite": "{email} запросив вас стати довіреною особою",
|
||||
"authToManageLegacy": "Авторизуйтесь, щоби керувати довіреними контактами",
|
||||
"useDifferentPlayerInfo": "Виникли проблеми з відтворенням цього відео? Натисніть і утримуйте тут, щоб спробувати інший плеєр."
|
||||
"useDifferentPlayerInfo": "Виникли проблеми з відтворенням цього відео? Натисніть і утримуйте тут, щоб спробувати інший плеєр.",
|
||||
"cLTitle1": "Схожі зображення",
|
||||
"cLDesc1": "Ми впроваджуємо нову систему на основі ML для виявлення схожих зображень, за допомогою якої ви можете очистити свою бібліотеку. Доступно в Налаштування->Резервна копія->Звільнити місце",
|
||||
"cLTitle2": "Покращення відео стрімінгу",
|
||||
"cLDesc2": "Тепер ви можете вручну запустити генерацію потоку для відео прямо з додатку. Ми також додали новий екран налаштувань відео стрімінгу, який покаже вам, який відсоток ваших відео було оброблено для стрімінгу",
|
||||
"cLTitle3": "Покращення продуктивності",
|
||||
"cLDesc3": "Численні покращення під капотом, включаючи краще використання кешу та більш плавну прокрутку"
|
||||
}
|
||||
@@ -1932,10 +1932,10 @@
|
||||
"hoorayyyy": "Hoorayyyy!",
|
||||
"nothingToTidyUpHere": "Ở đây đã ngon lành rồi",
|
||||
"deletingDash": "Đang xóa - ",
|
||||
"cLTitle1": "Ảnh giống nhau",
|
||||
"cLDesc1": "Chúng tôi ra mắt một hệ thống dựa trên ML mới để phát hiện các hình ảnh giống nhau, giúp bạn có thể dọn dẹp thư viện của mình. Có sẵn trong Cài đặt->Sao lưu->Giải phóng dung lượng",
|
||||
"cLTitle2": "Nâng cao phát video",
|
||||
"cLDesc2": "Giờ đây, bạn có thể kích hoạt thủ công việc tạo luồng video trực tiếp từ ứng dụng. Chúng tôi cũng đã thêm màn hình cài đặt phát trực tuyến video mới, hiển thị tỷ lệ phần trăm video đã được xử lý để phát trực tuyến",
|
||||
"cLTitle3": "Cải thiện hiệu suất",
|
||||
"cLDesc3": "Nhiều cải tiến bên trong, bao gồm sử dụng bộ nhớ đệm tốt hơn và trải nghiệm cuộn mượt mà hơn"
|
||||
}
|
||||
"cLTitle1": "Hình ảnh tương tự",
|
||||
"cLDesc1": "Chúng tôi đang giới thiệu một hệ thống dựa trên ML mới để phát hiện hình ảnh tương tự, bạn có thể dùng để dọn dẹp thư viện của mình. Có sẵn trong Cài đặt -> Sao lưu -> Giải phóng dung lượng",
|
||||
"cLTitle2": "Cải thiện streaming video",
|
||||
"cLDesc2": "Bây giờ bạn có thể kích hoạt tạo luồng cho video trực tiếp từ ứng dụng. Chúng tôi cũng đã thêm màn hình cài đặt phát trực tuyến video mới sẽ cho bạn biết bao nhiêu phần trăm video của bạn đã được xử lý để phát trực tuyến",
|
||||
"cLTitle3": "Cải Thiện Hiệu Suất",
|
||||
"cLDesc3": "Nhiều cải thiện bên trong, bao gồm sử dụng bộ nhớ đệm tốt hơn và trải nghiệm cuộn mượt mà hơn"
|
||||
}
|
||||
|
||||
@@ -1917,11 +1917,6 @@
|
||||
},
|
||||
"size": "大小",
|
||||
"similarity": "相似度",
|
||||
"analyzingPhotosLocally": "正在本地分析您的照片...",
|
||||
"lookingForVisualSimilarities": "正在寻找视觉相似点……",
|
||||
"comparingImageDetails": "正在比较图像细节...",
|
||||
"findingSimilarImages": "正在寻找相似图片...",
|
||||
"almostDone": "即将完成...",
|
||||
"processingLocally": "正在本地处理",
|
||||
"useMLToFindSimilarImages": "审查并删除看起来彼此相似的图像。",
|
||||
"all": "全部",
|
||||
@@ -1931,11 +1926,10 @@
|
||||
"related": "相关",
|
||||
"hoorayyyy": "耶~~!",
|
||||
"nothingToTidyUpHere": "这里没什么可清理的",
|
||||
"deletingDash": "正在删除 - ",
|
||||
"cLTitle1": "相似图片",
|
||||
"cLDesc1": "我们推出了一个基于机器学习的全新系统来检测相似图片,您可以使用它来清理图库。您可以在“设置”->“备份”->“释放空间”中找到它。",
|
||||
"cLTitle2": "视频流增强功能",
|
||||
"cLDesc2": "您现在可以直接从应用程序手动触发视频流生成。我们还添加了一个新的视频流设置屏幕,它将显示已处理的视频流百分比",
|
||||
"cLTitle1": "相似图像",
|
||||
"cLDesc1": "我们正在推出一个基于机器学习的新系统来检测相似图像,您可以用它来清理您的图库。在 设置 -> 备份 -> 释放空间 中可用",
|
||||
"cLTitle2": "视频流媒体增强",
|
||||
"cLDesc2": "您现在可以直接从应用程序手动触发视频的流生成。我们还添加了一个新的视频流设置屏幕,它将显示您的视频中有百分之几已被处理用于流媒体播放",
|
||||
"cLTitle3": "性能改进",
|
||||
"cLDesc3": "多项底层改进,包括更好的缓存使用和更流畅的滚动体验"
|
||||
}
|
||||
"cLDesc3": "多个底层改进,包括更好的缓存使用和更流畅的滚动体验"
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import "package:photos/models/collection/collection_items.dart";
|
||||
import "package:photos/models/search/search_result.dart";
|
||||
import "package:photos/models/typedefs.dart";
|
||||
import "package:photos/services/collections_service.dart";
|
||||
import "package:photos/services/machine_learning/face_ml/face_filtering/face_filtering_constants.dart";
|
||||
import "package:photos/services/search_service.dart";
|
||||
import "package:photos/ui/viewer/gallery/collection_page.dart";
|
||||
import "package:photos/ui/viewer/location/add_location_sheet.dart";
|
||||
@@ -220,7 +221,12 @@ extension SectionTypeExtensions on SectionType {
|
||||
}) {
|
||||
switch (this) {
|
||||
case SectionType.face:
|
||||
return SearchService.instance.getAllFace(limit);
|
||||
return SearchService.instance.getAllFace(
|
||||
limit,
|
||||
minClusterSize: limit == null
|
||||
? kMinimumClusterSizeAllFaces
|
||||
: kMinimumClusterSizeSearchResult,
|
||||
);
|
||||
case SectionType.magic:
|
||||
return SearchService.instance.getMagicSectionResults(context!);
|
||||
case SectionType.location:
|
||||
|
||||
@@ -19,6 +19,7 @@ import "package:photos/services/smart_albums_service.dart";
|
||||
import "package:photos/services/smart_memories_service.dart";
|
||||
import "package:photos/services/storage_bonus_service.dart";
|
||||
import "package:photos/services/sync/trash_sync_service.dart";
|
||||
import "package:photos/services/text_embeddings_cache_service.dart";
|
||||
import "package:photos/services/update_service.dart";
|
||||
import "package:photos/utils/local_settings.dart";
|
||||
import "package:shared_preferences/shared_preferences.dart";
|
||||
@@ -136,6 +137,12 @@ SmartMemoriesService get smartMemoriesService {
|
||||
return _smartMemoriesService!;
|
||||
}
|
||||
|
||||
TextEmbeddingsCacheService? _textEmbeddingsCacheService;
|
||||
TextEmbeddingsCacheService get textEmbeddingsCacheService {
|
||||
_textEmbeddingsCacheService ??= TextEmbeddingsCacheService.instance;
|
||||
return _textEmbeddingsCacheService!;
|
||||
}
|
||||
|
||||
BillingService? _billingService;
|
||||
BillingService get billingService {
|
||||
_billingService ??= BillingService(
|
||||
|
||||
@@ -110,7 +110,7 @@ class DateParseService {
|
||||
|
||||
result = _parseStructuredFormats(lowerInput);
|
||||
if (!result.isEmpty) return result;
|
||||
|
||||
|
||||
final normalized = _normalizeDateString(lowerInput);
|
||||
result = _parseTokenizedDate(normalized);
|
||||
|
||||
@@ -203,7 +203,7 @@ class DateParseService {
|
||||
}
|
||||
return PartialDate.empty;
|
||||
}
|
||||
|
||||
|
||||
match = _standardFormatRegex.firstMatch(cleanInput);
|
||||
if (match != null) {
|
||||
final p1 = int.parse(match.group(1)!);
|
||||
|
||||
@@ -193,15 +193,22 @@ class SemanticSearchService {
|
||||
return results;
|
||||
}
|
||||
|
||||
Future<Map<String, List<int>>> getMatchingFileIDs(
|
||||
/// Get matching file IDs for common repeated queries like smart memories and magic cache.
|
||||
/// WARNING: Use this method carefully - it uses persistent caching which is only
|
||||
/// beneficial for queries that are repeated across app sessions.
|
||||
/// For regular user searches, use getMatchingFiles instead.
|
||||
Future<Map<String, List<int>>> getMatchingFileIDsForCommonQueries(
|
||||
Map<String, double> queryToScore,
|
||||
) async {
|
||||
final textEmbeddings = <String, List<double>>{};
|
||||
final minimumSimilarityMap = <String, double>{};
|
||||
|
||||
for (final entry in queryToScore.entries) {
|
||||
final query = entry.key;
|
||||
final score = entry.value;
|
||||
final textEmbedding = await _getTextEmbedding(query);
|
||||
// Use cache service instead of _getTextEmbedding
|
||||
final textEmbedding =
|
||||
await textEmbeddingsCacheService.getEmbedding(query);
|
||||
textEmbeddings[query] = textEmbedding;
|
||||
minimumSimilarityMap[query] = score;
|
||||
}
|
||||
@@ -210,6 +217,7 @@ class SemanticSearchService {
|
||||
textEmbeddings,
|
||||
minimumSimilarityMap: minimumSimilarityMap,
|
||||
);
|
||||
|
||||
final result = <String, List<int>>{};
|
||||
for (final entry in queryResults.entries) {
|
||||
final query = entry.key;
|
||||
|
||||
@@ -401,8 +401,8 @@ class MagicCacheService {
|
||||
for (Prompt prompt in magicPromptsData) {
|
||||
queryToScore[prompt.query] = prompt.minScore;
|
||||
}
|
||||
final clipResults =
|
||||
await SemanticSearchService.instance.getMatchingFileIDs(queryToScore);
|
||||
final clipResults = await SemanticSearchService.instance
|
||||
.getMatchingFileIDsForCommonQueries(queryToScore);
|
||||
for (Prompt prompt in magicPromptsData) {
|
||||
final List<int> fileUploadedIDs = clipResults[prompt.query] ?? [];
|
||||
if (fileUploadedIDs.isNotEmpty) {
|
||||
|
||||
@@ -46,7 +46,6 @@ import 'package:photos/services/collections_service.dart';
|
||||
import "package:photos/services/date_parse_service.dart";
|
||||
import "package:photos/services/filter/db_filters.dart";
|
||||
import "package:photos/services/location_service.dart";
|
||||
import "package:photos/services/machine_learning/face_ml/face_filtering/face_filtering_constants.dart";
|
||||
import "package:photos/services/machine_learning/face_ml/person/person_service.dart";
|
||||
import 'package:photos/services/machine_learning/semantic_search/semantic_search_service.dart';
|
||||
import "package:photos/services/memories_cache_service.dart";
|
||||
@@ -725,7 +724,7 @@ class SearchService {
|
||||
|
||||
Future<List<GenericSearchResult>> getAllFace(
|
||||
int? limit, {
|
||||
int minClusterSize = kMinimumClusterSizeSearchResult,
|
||||
required int minClusterSize,
|
||||
}) async {
|
||||
try {
|
||||
debugPrint("getting faces");
|
||||
@@ -894,13 +893,7 @@ class SearchService {
|
||||
),
|
||||
);
|
||||
}
|
||||
if (facesResult.isEmpty) {
|
||||
if (kMinimumClusterSizeAllFaces < minClusterSize) {
|
||||
return getAllFace(limit, minClusterSize: kMinimumClusterSizeAllFaces);
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
if (facesResult.isEmpty) return [];
|
||||
if (limit != null) {
|
||||
return facesResult.sublist(0, min(limit, facesResult.length));
|
||||
} else {
|
||||
|
||||
@@ -37,7 +37,6 @@ import "package:photos/services/location_service.dart";
|
||||
import "package:photos/services/machine_learning/face_ml/person/person_service.dart";
|
||||
import "package:photos/services/machine_learning/ml_result.dart";
|
||||
import "package:photos/services/search_service.dart";
|
||||
import "package:photos/utils/text_embeddings_util.dart";
|
||||
|
||||
class MemoriesResult {
|
||||
final List<SmartMemory> memories;
|
||||
@@ -103,18 +102,29 @@ class SmartMemoriesService {
|
||||
'allImageEmbeddings has ${allImageEmbeddings.length} entries $t',
|
||||
);
|
||||
|
||||
// Load pre-computed text embeddings from assets
|
||||
final textEmbeddings = await loadTextEmbeddingsFromAssets();
|
||||
if (textEmbeddings == null) {
|
||||
_logger.severe('Failed to load pre-computed text embeddings');
|
||||
throw Exception(
|
||||
'Failed to load pre-computed text embeddings',
|
||||
_logger.info('Loading text embeddings via cache service');
|
||||
final clipPositiveTextVector = Vector.fromList(
|
||||
await textEmbeddingsCacheService.getEmbedding(
|
||||
"Photo of a precious and nostalgic memory radiating warmth, vibrant energy, or quiet beauty — alive with color, light, or emotion",
|
||||
),
|
||||
);
|
||||
|
||||
final clipPeopleActivityVectors = <PeopleActivity, Vector>{};
|
||||
for (final activity in PeopleActivity.values) {
|
||||
final query = activityQuery(activity);
|
||||
clipPeopleActivityVectors[activity] = Vector.fromList(
|
||||
await textEmbeddingsCacheService.getEmbedding(query),
|
||||
);
|
||||
}
|
||||
_logger.info('Using pre-computed text embeddings from assets');
|
||||
final clipPositiveTextVector = textEmbeddings.clipPositiveVector;
|
||||
final clipPeopleActivityVectors = textEmbeddings.peopleActivityVectors;
|
||||
final clipMemoryTypeVectors = textEmbeddings.clipMemoryTypeVectors;
|
||||
|
||||
final clipMemoryTypeVectors = <ClipMemoryType, Vector>{};
|
||||
for (final memoryType in ClipMemoryType.values) {
|
||||
final query = clipQuery(memoryType);
|
||||
clipMemoryTypeVectors[memoryType] = Vector.fromList(
|
||||
await textEmbeddingsCacheService.getEmbedding(query),
|
||||
);
|
||||
}
|
||||
_logger.info('Text embeddings loaded via cache service');
|
||||
|
||||
final local = await getLocale();
|
||||
final languageCode = local?.languageCode ?? "en";
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:photos/db/ml/db.dart';
|
||||
import 'package:photos/services/machine_learning/ml_computer.dart';
|
||||
|
||||
class TextEmbeddingsCacheService {
|
||||
static final _logger = Logger('TextEmbeddingsCacheService');
|
||||
|
||||
TextEmbeddingsCacheService._privateConstructor();
|
||||
static final instance = TextEmbeddingsCacheService._privateConstructor();
|
||||
|
||||
Future<List<double>> getEmbedding(String query) async {
|
||||
// 1. Check database cache
|
||||
final dbResult =
|
||||
await MLDataDB.instance.getRepeatedTextEmbeddingCache(query);
|
||||
if (dbResult != null) {
|
||||
_logger.info('Text embedding cache hit for query');
|
||||
return dbResult;
|
||||
}
|
||||
|
||||
// 2. Compute new embedding
|
||||
_logger.info('Computing new text embedding for query');
|
||||
final embedding = await MLComputer.instance.runClipText(query);
|
||||
|
||||
// 3. Store in database cache
|
||||
await MLDataDB.instance.putRepeatedTextEmbeddingCache(query, embedding);
|
||||
|
||||
return embedding;
|
||||
}
|
||||
}
|
||||
@@ -344,7 +344,8 @@ class _PasswordEntryPageState extends State<PasswordEntryPage> {
|
||||
const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
|
||||
child: Text(
|
||||
AppLocalizations.of(context).passwordStrength(
|
||||
passwordStrengthValue: passwordStrengthText,),
|
||||
passwordStrengthValue: passwordStrengthText,
|
||||
),
|
||||
style: TextStyle(
|
||||
color: passwordStrengthColor,
|
||||
),
|
||||
|
||||
@@ -23,6 +23,7 @@ class DebugSectionWidget extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _DebugSectionWidgetState extends State<DebugSectionWidget> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ExpandableMenuItemWidget(
|
||||
@@ -35,6 +36,27 @@ class _DebugSectionWidgetState extends State<DebugSectionWidget> {
|
||||
Widget _getSectionOptions(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
sectionOptionSpacing,
|
||||
MenuItemWidget(
|
||||
captionedTextWidget: const CaptionedTextWidget(
|
||||
title: "Enable database logging",
|
||||
),
|
||||
pressedColor: getEnteColorScheme(context).fillFaint,
|
||||
trailingWidget: ToggleSwitchWidget(
|
||||
value: () => localSettings.enableDatabaseLogging,
|
||||
onChanged: () async {
|
||||
final newValue = !localSettings.enableDatabaseLogging;
|
||||
await localSettings.setEnableDatabaseLogging(newValue);
|
||||
setState(() {});
|
||||
showShortToast(
|
||||
context,
|
||||
newValue
|
||||
? "Database logging enabled. Restart app."
|
||||
: "Database logging disabled. Restart app.",
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
sectionOptionSpacing,
|
||||
MenuItemWidget(
|
||||
captionedTextWidget: const CaptionedTextWidget(
|
||||
|
||||
@@ -14,7 +14,8 @@ class DeveloperSettingsWidget extends StatelessWidget {
|
||||
padding: const EdgeInsets.only(bottom: 20),
|
||||
child: Text(
|
||||
AppLocalizations.of(context).customEndpoint(
|
||||
endpoint: "${endpointURI.host}:${endpointURI.port}",),
|
||||
endpoint: "${endpointURI.host}:${endpointURI.port}",
|
||||
),
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -283,7 +283,9 @@ class _StorageCardWidgetState extends State<StorageCardWidget> {
|
||||
: const SizedBox.shrink(),
|
||||
Text(
|
||||
AppLocalizations.of(context).availableStorageSpace(
|
||||
freeAmount: freeSpace, storageUnit: freeSpaceUnit,),
|
||||
freeAmount: freeSpace,
|
||||
storageUnit: freeSpaceUnit,
|
||||
),
|
||||
style: getEnteTextTheme(context)
|
||||
.mini
|
||||
.copyWith(color: textFaintDark),
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import "package:flutter_animate/flutter_animate.dart";
|
||||
import "package:log_viewer/log_viewer.dart";
|
||||
import 'package:photos/core/configuration.dart';
|
||||
import 'package:photos/core/event_bus.dart';
|
||||
import 'package:photos/events/opened_settings_event.dart';
|
||||
@@ -35,6 +36,7 @@ class SettingsPage extends StatelessWidget {
|
||||
|
||||
const SettingsPage({super.key, required this.emailNotifier});
|
||||
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Bus.instance.fire(OpenedSettingsEvent());
|
||||
@@ -70,12 +72,36 @@ class SettingsPage extends StatelessWidget {
|
||||
// [AnimatedBuilder] accepts any [Listenable] subtype.
|
||||
animation: emailNotifier,
|
||||
builder: (BuildContext context, Widget? child) {
|
||||
return Text(
|
||||
emailNotifier.value!,
|
||||
style: enteTextTheme.body.copyWith(
|
||||
color: colorScheme.textMuted,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
emailNotifier.value!,
|
||||
style: enteTextTheme.body.copyWith(
|
||||
color: colorScheme.textMuted,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (localSettings.enableDatabaseLogging)
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const LogViewerPage(),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Icon(
|
||||
Icons.bug_report,
|
||||
size: 20,
|
||||
color: colorScheme.textMuted,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -7,6 +7,7 @@ import "package:flutter/services.dart";
|
||||
import "package:photos/generated/l10n.dart";
|
||||
import "package:photos/models/api/collection/public_url.dart";
|
||||
import 'package:photos/models/collection/collection.dart';
|
||||
import 'package:photos/service_locator.dart';
|
||||
import 'package:photos/services/collections_service.dart';
|
||||
import 'package:photos/theme/colors.dart';
|
||||
import 'package:photos/theme/ente_theme.dart';
|
||||
@@ -19,6 +20,7 @@ import "package:photos/ui/components/toggle_switch_widget.dart";
|
||||
import 'package:photos/ui/notification/toast.dart';
|
||||
import 'package:photos/ui/sharing/pickers/device_limit_picker_page.dart';
|
||||
import 'package:photos/ui/sharing/pickers/link_expiry_picker_page.dart';
|
||||
import 'package:photos/ui/sharing/qr_code_dialog_widget.dart';
|
||||
import 'package:photos/utils/dialog_util.dart';
|
||||
import 'package:photos/utils/navigation_util.dart';
|
||||
import "package:photos/utils/share_util.dart";
|
||||
@@ -51,6 +53,8 @@ class _ManageSharedLinkWidgetState extends State<ManageSharedLinkWidget> {
|
||||
widget.collection!.publicURLs.firstOrNull?.enableDownload ?? true;
|
||||
final isPasswordEnabled =
|
||||
widget.collection!.publicURLs.firstOrNull?.passwordEnabled ?? false;
|
||||
final isJoinEnabled =
|
||||
widget.collection!.publicURLs.firstOrNull?.enableJoin ?? false;
|
||||
final enteColorScheme = getEnteColorScheme(context);
|
||||
final PublicURL url = widget.collection!.publicURLs.firstOrNull!;
|
||||
final String urlValue =
|
||||
@@ -92,6 +96,31 @@ class _ManageSharedLinkWidgetState extends State<ManageSharedLinkWidget> {
|
||||
AppLocalizations.of(context).allowAddPhotosDescription,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
if (flagService.internalUser)
|
||||
MenuItemWidget(
|
||||
key: ValueKey("Allow join $isJoinEnabled"),
|
||||
captionedTextWidget: const CaptionedTextWidget(
|
||||
title: "Allow joining album (i)",
|
||||
),
|
||||
alignCaptionedTextToLeft: true,
|
||||
menuItemColor: getEnteColorScheme(context).fillFaint,
|
||||
trailingWidget: ToggleSwitchWidget(
|
||||
value: () => isJoinEnabled,
|
||||
onChanged: () async {
|
||||
await _updateUrlSettings(
|
||||
context,
|
||||
{'enableJoin': !isJoinEnabled},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
if (flagService.internalUser)
|
||||
MenuSectionDescriptionWidget(
|
||||
content: isCollectEnabled
|
||||
? "Allow people with link to join your album as Collaborator"
|
||||
: "Allow people with link to join your album as Viewer",
|
||||
),
|
||||
if (flagService.internalUser) const SizedBox(height: 24),
|
||||
MenuItemWidget(
|
||||
alignCaptionedTextToLeft: true,
|
||||
captionedTextWidget: CaptionedTextWidget(
|
||||
@@ -291,6 +320,32 @@ class _ManageSharedLinkWidgetState extends State<ManageSharedLinkWidget> {
|
||||
);
|
||||
},
|
||||
isTopBorderRadiusRemoved: true,
|
||||
isBottomBorderRadiusRemoved: flagService.internalUser,
|
||||
),
|
||||
if (!url.isExpired && flagService.internalUser)
|
||||
DividerWidget(
|
||||
dividerType: DividerType.menu,
|
||||
bgColor: getEnteColorScheme(context).fillFaint,
|
||||
),
|
||||
if (!url.isExpired && flagService.internalUser)
|
||||
MenuItemWidget(
|
||||
captionedTextWidget: const CaptionedTextWidget(
|
||||
title: "Send QR Code (i)",
|
||||
makeTextBold: true,
|
||||
),
|
||||
leadingIcon: Icons.qr_code_outlined,
|
||||
menuItemColor: getEnteColorScheme(context).fillFaint,
|
||||
onTap: () async {
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return QrCodeDialogWidget(
|
||||
collection: widget.collection!,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
isTopBorderRadiusRemoved: true,
|
||||
),
|
||||
const SizedBox(
|
||||
height: 24,
|
||||
|
||||
211
mobile/apps/photos/lib/ui/sharing/qr_code_dialog_widget.dart
Normal file
@@ -0,0 +1,211 @@
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:photos/models/collection/collection.dart';
|
||||
import 'package:photos/services/collections_service.dart';
|
||||
import 'package:photos/theme/ente_theme.dart';
|
||||
import 'package:photos/ui/components/buttons/button_widget.dart';
|
||||
import 'package:photos/ui/components/models/button_type.dart';
|
||||
import 'package:qr_flutter/qr_flutter.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
|
||||
class QrCodeDialogWidget extends StatefulWidget {
|
||||
final Collection collection;
|
||||
|
||||
const QrCodeDialogWidget({
|
||||
super.key,
|
||||
required this.collection,
|
||||
});
|
||||
|
||||
@override
|
||||
State<QrCodeDialogWidget> createState() => _QrCodeDialogWidgetState();
|
||||
}
|
||||
|
||||
class _QrCodeDialogWidgetState extends State<QrCodeDialogWidget> {
|
||||
final GlobalKey _qrKey = GlobalKey();
|
||||
|
||||
Future<void> _shareQrCode() async {
|
||||
try {
|
||||
final RenderRepaintBoundary boundary =
|
||||
_qrKey.currentContext!.findRenderObject() as RenderRepaintBoundary;
|
||||
final ui.Image image = await boundary.toImage(pixelRatio: 3.0);
|
||||
final ByteData? byteData =
|
||||
await image.toByteData(format: ui.ImageByteFormat.png);
|
||||
if (byteData != null) {
|
||||
final Uint8List pngBytes = byteData.buffer.asUint8List();
|
||||
|
||||
final directory = await getTemporaryDirectory();
|
||||
final file = File(
|
||||
'${directory.path}/ente_qr_${widget.collection.displayName}.png',
|
||||
);
|
||||
await file.writeAsBytes(pngBytes);
|
||||
|
||||
await Share.shareXFiles(
|
||||
[XFile(file.path)],
|
||||
text:
|
||||
'Scan this QR code to view my ${widget.collection.displayName} album on ente',
|
||||
);
|
||||
|
||||
// Close the dialog after sharing is initiated
|
||||
if (mounted) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Error sharing QR code: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
final double qrSize = min(screenWidth - 80, 300.0);
|
||||
final enteTextTheme = getEnteTextTheme(context);
|
||||
final enteColorScheme = getEnteColorScheme(context);
|
||||
|
||||
// Get the public URL for the collection
|
||||
final String publicUrl =
|
||||
CollectionsService.instance.getPublicUrl(widget.collection);
|
||||
|
||||
// Get album name, truncate if too long
|
||||
final String albumName = widget.collection.displayName.length > 30
|
||||
? '${widget.collection.displayName.substring(0, 27)}...'
|
||||
: widget.collection.displayName;
|
||||
|
||||
return Dialog(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: enteColorScheme.backgroundBase,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Header with close button
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
"QR Code",
|
||||
style: enteTextTheme.largeBold,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
color: enteColorScheme.strokeBase,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// QR Code with RepaintBoundary for sharing
|
||||
RepaintBoundary(
|
||||
key: _qrKey,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(28),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: Colors.grey.shade200,
|
||||
width: 1,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.04),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Album name at top center (inside border) - Reduced size
|
||||
Text(
|
||||
albumName,
|
||||
style: enteTextTheme.bodyBold.copyWith(
|
||||
color: Colors.black87,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.3,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// QR Code with better spacing
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: Colors.grey.shade100,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: QrImageView(
|
||||
data: publicUrl,
|
||||
version: QrVersions.auto,
|
||||
size: qrSize - 100,
|
||||
eyeStyle: const QrEyeStyle(
|
||||
eyeShape: QrEyeShape.square,
|
||||
color: Colors.black,
|
||||
),
|
||||
dataModuleStyle: const QrDataModuleStyle(
|
||||
dataModuleShape: QrDataModuleShape.square,
|
||||
color: Colors.black,
|
||||
),
|
||||
errorCorrectionLevel: QrErrorCorrectLevel.M,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
),
|
||||
|
||||
// Ente branding at bottom right (inside border) - Fixed positioning
|
||||
Positioned(
|
||||
bottom: -2,
|
||||
right: 2,
|
||||
child: Text(
|
||||
'ente',
|
||||
style: enteTextTheme.small.copyWith(
|
||||
color: enteColorScheme.primary700,
|
||||
fontSize: 14,
|
||||
letterSpacing: 1.2,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Share button
|
||||
ButtonWidget(
|
||||
buttonType: ButtonType.primary,
|
||||
icon: Icons.adaptive.share,
|
||||
labelText: "Share",
|
||||
onTap: _shareQrCode,
|
||||
shouldSurfaceExecutionStates: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import "package:photos/extensions/user_extension.dart";
|
||||
import "package:photos/generated/l10n.dart";
|
||||
import "package:photos/models/api/collection/user.dart";
|
||||
import 'package:photos/models/collection/collection.dart';
|
||||
import 'package:photos/service_locator.dart';
|
||||
import 'package:photos/services/collections_service.dart';
|
||||
import 'package:photos/theme/ente_theme.dart';
|
||||
import 'package:photos/ui/actions/collection/collection_sharing_actions.dart';
|
||||
@@ -19,6 +21,7 @@ import 'package:photos/ui/sharing/album_participants_page.dart';
|
||||
import "package:photos/ui/sharing/album_share_info_widget.dart";
|
||||
import "package:photos/ui/sharing/manage_album_participant.dart";
|
||||
import 'package:photos/ui/sharing/manage_links_widget.dart';
|
||||
import 'package:photos/ui/sharing/qr_code_dialog_widget.dart';
|
||||
import 'package:photos/ui/sharing/user_avator_widget.dart';
|
||||
import 'package:photos/utils/navigation_util.dart';
|
||||
import 'package:photos/utils/share_util.dart';
|
||||
@@ -214,8 +217,34 @@ class _ShareCollectionPageState extends State<ShareCollectionPage> {
|
||||
);
|
||||
},
|
||||
isTopBorderRadiusRemoved: true,
|
||||
isBottomBorderRadiusRemoved: true,
|
||||
isBottomBorderRadiusRemoved: flagService.internalUser,
|
||||
),
|
||||
if (flagService.internalUser)
|
||||
DividerWidget(
|
||||
dividerType: DividerType.menu,
|
||||
bgColor: getEnteColorScheme(context).fillFaint,
|
||||
),
|
||||
if (flagService.internalUser)
|
||||
MenuItemWidget(
|
||||
captionedTextWidget: const CaptionedTextWidget(
|
||||
title: "Send QR Code (i)",
|
||||
makeTextBold: true,
|
||||
),
|
||||
leadingIcon: Icons.qr_code_outlined,
|
||||
menuItemColor: getEnteColorScheme(context).fillFaint,
|
||||
onTap: () async {
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return QrCodeDialogWidget(
|
||||
collection: widget.collection,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
isTopBorderRadiusRemoved: true,
|
||||
isBottomBorderRadiusRemoved: true,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -180,7 +180,8 @@ class _VerifyIdentifyDialogState extends State<VerifyIdentifyDialog> {
|
||||
.shareMyVerificationID(verificationID: verificationID)
|
||||
: AppLocalizations.of(context)
|
||||
.shareTextConfirmOthersVerificationID(
|
||||
verificationID: verificationID,),
|
||||
verificationID: verificationID,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
|
||||
@@ -366,7 +366,8 @@ class _ImageEditorPageState extends State<ImageEditorPage> {
|
||||
margin: const EdgeInsets.only(bottom: 24),
|
||||
decoration: BoxDecoration(
|
||||
color: isHovered
|
||||
? colorScheme.warning400.withValues(alpha: 0.8)
|
||||
? colorScheme.warning400
|
||||
.withValues(alpha: 0.8)
|
||||
: Colors.white,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
|
||||
@@ -54,7 +54,9 @@ class _FreeSpacePageState extends State<FreeSpacePage> {
|
||||
? AppLocalizations.of(context)
|
||||
.filesBackedUpInAlbum(count: count, formattedNumber: formattedCount)
|
||||
: AppLocalizations.of(context).filesBackedUpFromDevice(
|
||||
count: count, formattedNumber: formattedCount,);
|
||||
count: count,
|
||||
formattedNumber: formattedCount,
|
||||
);
|
||||
final informationTextStyle = TextStyle(
|
||||
fontSize: 14,
|
||||
height: 1.3,
|
||||
@@ -121,7 +123,9 @@ class _FreeSpacePageState extends State<FreeSpacePage> {
|
||||
Expanded(
|
||||
child: Text(
|
||||
AppLocalizations.of(context).freeUpSpaceSaving(
|
||||
count: count, formattedSize: formatBytes(status.size),),
|
||||
count: count,
|
||||
formattedSize: formatBytes(status.size),
|
||||
),
|
||||
style: informationTextStyle,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -153,8 +153,9 @@ class _DeleteEmptyAlbumsState extends State<DeleteEmptyAlbums> {
|
||||
.toString()
|
||||
.padLeft(collections.length.toString().length, '0');
|
||||
_deleteProgress.value = AppLocalizations.of(context).deleteProgress(
|
||||
currentlyDeleting: currentlyDeleting,
|
||||
totalCount: collections.length,);
|
||||
currentlyDeleting: currentlyDeleting,
|
||||
totalCount: collections.length,
|
||||
);
|
||||
try {
|
||||
await CollectionsService.instance.trashEmptyCollection(
|
||||
collections[i],
|
||||
|
||||
@@ -47,15 +47,16 @@ class _VideoStreamChangeWidgetState extends State<VideoStreamChangeWidget> {
|
||||
final status = event.status;
|
||||
|
||||
// Handle different states - will be false for different files or non-processing states
|
||||
final newProcessingState = widget.file.uploadedFileID == fileId && switch (status) {
|
||||
PreviewItemStatus.inQueue ||
|
||||
PreviewItemStatus.retry ||
|
||||
PreviewItemStatus.compressing ||
|
||||
PreviewItemStatus.uploading =>
|
||||
true,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
final newProcessingState = widget.file.uploadedFileID == fileId &&
|
||||
switch (status) {
|
||||
PreviewItemStatus.inQueue ||
|
||||
PreviewItemStatus.retry ||
|
||||
PreviewItemStatus.compressing ||
|
||||
PreviewItemStatus.uploading =>
|
||||
true,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
// Only update state if value changed
|
||||
if (isCurrentlyProcessing != newProcessingState) {
|
||||
isCurrentlyProcessing = newProcessingState;
|
||||
|
||||
@@ -224,7 +224,8 @@ class _GalleryAppBarWidgetState extends State<GalleryAppBarWidget> {
|
||||
context,
|
||||
AppLocalizations.of(context)
|
||||
.typeOfGallerGallerytypeIsNotSupportedForRename(
|
||||
galleryType: "$galleryType",),
|
||||
galleryType: "$galleryType",
|
||||
),
|
||||
);
|
||||
|
||||
return;
|
||||
|
||||
@@ -104,7 +104,8 @@ class _PersonClustersState extends State<PersonReviewClusterSuggestion> {
|
||||
return Center(
|
||||
child: Text(
|
||||
AppLocalizations.of(context).noSuggestionsForPerson(
|
||||
personName: widget.person.data.name,),
|
||||
personName: widget.person.data.name,
|
||||
),
|
||||
style: getEnteTextTheme(context).largeMuted,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -156,7 +156,9 @@ class _LinkContactToPersonSelectionPageState
|
||||
title: context.l10n.linkPersonToEmail(email: emailToLink),
|
||||
icon: Icons.info_outline,
|
||||
body: context.l10n.linkPersonToEmailConfirmation(
|
||||
personName: personName, email: emailToLink,),
|
||||
personName: personName,
|
||||
email: emailToLink,
|
||||
),
|
||||
isDismissible: true,
|
||||
buttons: [
|
||||
ButtonWidget(
|
||||
|
||||
@@ -252,7 +252,7 @@ class SearchWidgetState extends State<SearchWidget> {
|
||||
},
|
||||
);
|
||||
|
||||
_searchService.getAllFace(null).then(
|
||||
_searchService.getAllFace(null, minClusterSize: 10).then(
|
||||
(faceResult) {
|
||||
final List<GenericSearchResult> filteredResults = [];
|
||||
for (final result in faceResult) {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'package:photos/core/constants.dart';
|
||||
import 'package:photos/ui/viewer/gallery/component/group/type.dart';
|
||||
import "package:photos/utils/ram_check_util.dart";
|
||||
@@ -42,7 +44,8 @@ class LocalSettings {
|
||||
static const kCollectionViewType = "collection_view_type";
|
||||
static const kCollectionSortDirection = "collection_sort_direction";
|
||||
static const kShowLocalIDOverThumbnails = "show_local_id_over_thumbnails";
|
||||
|
||||
static const kEnableDatabaseLogging = "enable_db_logging";
|
||||
|
||||
// Thumbnail queue configuration keys
|
||||
static const kSmallQueueMaxConcurrent = "small_queue_max_concurrent";
|
||||
static const kSmallQueueTimeout = "small_queue_timeout_seconds";
|
||||
@@ -234,40 +237,49 @@ class LocalSettings {
|
||||
await _prefs.setBool(kShowLocalIDOverThumbnails, value);
|
||||
}
|
||||
|
||||
bool get enableDatabaseLogging =>
|
||||
_prefs.getBool(kEnableDatabaseLogging) ?? kDebugMode;
|
||||
|
||||
Future<void> setEnableDatabaseLogging(bool value) async {
|
||||
await _prefs.setBool(kEnableDatabaseLogging, value);
|
||||
}
|
||||
|
||||
// Thumbnail queue configuration - Small queue
|
||||
int get smallQueueMaxConcurrent => _prefs.getInt(kSmallQueueMaxConcurrent) ?? 15;
|
||||
|
||||
int get smallQueueMaxConcurrent =>
|
||||
_prefs.getInt(kSmallQueueMaxConcurrent) ?? 15;
|
||||
|
||||
int get smallQueueTimeoutSeconds => _prefs.getInt(kSmallQueueTimeout) ?? 60;
|
||||
|
||||
|
||||
int get smallQueueMaxSize => _prefs.getInt(kSmallQueueMaxSize) ?? 200;
|
||||
|
||||
|
||||
Future<void> setSmallQueueMaxConcurrent(int value) async {
|
||||
await _prefs.setInt(kSmallQueueMaxConcurrent, value);
|
||||
}
|
||||
|
||||
|
||||
Future<void> setSmallQueueTimeout(int seconds) async {
|
||||
await _prefs.setInt(kSmallQueueTimeout, seconds);
|
||||
}
|
||||
|
||||
|
||||
Future<void> setSmallQueueMaxSize(int value) async {
|
||||
await _prefs.setInt(kSmallQueueMaxSize, value);
|
||||
}
|
||||
|
||||
// Thumbnail queue configuration - Large queue
|
||||
int get largeQueueMaxConcurrent => _prefs.getInt(kLargeQueueMaxConcurrent) ?? 5;
|
||||
|
||||
int get largeQueueMaxConcurrent =>
|
||||
_prefs.getInt(kLargeQueueMaxConcurrent) ?? 5;
|
||||
|
||||
int get largeQueueTimeoutSeconds => _prefs.getInt(kLargeQueueTimeout) ?? 60;
|
||||
|
||||
|
||||
int get largeQueueMaxSize => _prefs.getInt(kLargeQueueMaxSize) ?? 200;
|
||||
|
||||
|
||||
Future<void> setLargeQueueMaxConcurrent(int value) async {
|
||||
await _prefs.setInt(kLargeQueueMaxConcurrent, value);
|
||||
}
|
||||
|
||||
|
||||
Future<void> setLargeQueueTimeout(int seconds) async {
|
||||
await _prefs.setInt(kLargeQueueTimeout, seconds);
|
||||
}
|
||||
|
||||
|
||||
Future<void> setLargeQueueMaxSize(int value) async {
|
||||
await _prefs.setInt(kLargeQueueMaxSize, value);
|
||||
}
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
import 'dart:convert';
|
||||
import "dart:developer" as dev show log;
|
||||
import "dart:io" show File;
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:ml_linalg/vector.dart';
|
||||
import "package:path_provider/path_provider.dart"
|
||||
show getExternalStorageDirectory;
|
||||
import 'package:photos/models/memories/clip_memory.dart';
|
||||
import 'package:photos/models/memories/people_memory.dart';
|
||||
import "package:photos/services/machine_learning/ml_computer.dart"
|
||||
show MLComputer;
|
||||
|
||||
final _logger = Logger('TextEmbeddingsUtil');
|
||||
|
||||
/// Loads pre-computed text embeddings from assets
|
||||
Future<TextEmbeddings?> loadTextEmbeddingsFromAssets() async {
|
||||
try {
|
||||
_logger.info('Loading text embeddings from assets');
|
||||
final jsonString =
|
||||
await rootBundle.loadString('assets/ml/text_embeddings.json');
|
||||
final data = json.decode(jsonString) as Map<String, dynamic>;
|
||||
|
||||
final embeddings = data['embeddings'] as Map<String, dynamic>;
|
||||
|
||||
// Parse clip positive embedding
|
||||
Vector? clipPositiveVector;
|
||||
final clipPositive = embeddings['clip_positive'] as Map<String, dynamic>;
|
||||
final clipPositiveVectorData =
|
||||
(clipPositive['vector'] as List).cast<double>();
|
||||
if (clipPositiveVectorData.isNotEmpty) {
|
||||
clipPositiveVector = Vector.fromList(clipPositiveVectorData);
|
||||
}
|
||||
|
||||
// Parse people activities embeddings
|
||||
final Map<PeopleActivity, Vector> peopleActivityVectors = {};
|
||||
final peopleActivities =
|
||||
embeddings['people_activities'] as Map<String, dynamic>;
|
||||
for (final activity in PeopleActivity.values) {
|
||||
final activityName = activity.toString().split('.').last;
|
||||
if (peopleActivities.containsKey(activityName)) {
|
||||
final activityData =
|
||||
peopleActivities[activityName] as Map<String, dynamic>;
|
||||
final vector = (activityData['vector'] as List).cast<double>();
|
||||
if (vector.isNotEmpty) {
|
||||
peopleActivityVectors[activity] = Vector.fromList(vector);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse clip memory types embeddings
|
||||
final Map<ClipMemoryType, Vector> clipMemoryTypeVectors = {};
|
||||
final clipMemoryTypes =
|
||||
embeddings['clip_memory_types'] as Map<String, dynamic>;
|
||||
for (final memoryType in ClipMemoryType.values) {
|
||||
final typeName = memoryType.toString().split('.').last;
|
||||
if (clipMemoryTypes.containsKey(typeName)) {
|
||||
final typeData = clipMemoryTypes[typeName] as Map<String, dynamic>;
|
||||
final vector = (typeData['vector'] as List).cast<double>();
|
||||
if (vector.isNotEmpty) {
|
||||
clipMemoryTypeVectors[memoryType] = Vector.fromList(vector);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we have all required embeddings
|
||||
if (clipPositiveVector == null) {
|
||||
_logger.severe('Clip positive vector is missing');
|
||||
throw Exception('Clip positive vector is missing');
|
||||
}
|
||||
|
||||
if (peopleActivityVectors.length != PeopleActivity.values.length) {
|
||||
_logger.severe('Some people activity vectors are missing');
|
||||
throw Exception('Some people activity vectors are missing');
|
||||
}
|
||||
|
||||
if (clipMemoryTypeVectors.length != ClipMemoryType.values.length) {
|
||||
_logger.severe('Some clip memory type vectors are missing');
|
||||
throw Exception('Some clip memory type vectors are missing');
|
||||
}
|
||||
|
||||
_logger.info('Text embeddings loaded successfully from JSON assets');
|
||||
return TextEmbeddings(
|
||||
clipPositiveVector: clipPositiveVector,
|
||||
peopleActivityVectors: peopleActivityVectors,
|
||||
clipMemoryTypeVectors: clipMemoryTypeVectors,
|
||||
);
|
||||
} catch (e, stackTrace) {
|
||||
_logger.severe('Failed to load text embeddings from JSON', e, stackTrace);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class TextEmbeddings {
|
||||
final Vector clipPositiveVector;
|
||||
final Map<PeopleActivity, Vector> peopleActivityVectors;
|
||||
final Map<ClipMemoryType, Vector> clipMemoryTypeVectors;
|
||||
|
||||
const TextEmbeddings({
|
||||
required this.clipPositiveVector,
|
||||
required this.peopleActivityVectors,
|
||||
required this.clipMemoryTypeVectors,
|
||||
});
|
||||
}
|
||||
|
||||
/// Helper function to generate text embeddings and save them to a JSON file
|
||||
/// Run this once to generate the embeddings, then copy the output
|
||||
/// to assets/ml/text_embeddings.json
|
||||
Future<void> generateAndSaveTextEmbeddings() async {
|
||||
final Map<String, dynamic> embeddingsData = {
|
||||
'version': '1.0.0',
|
||||
'embeddings': {
|
||||
'clip_positive': {},
|
||||
'people_activities': {},
|
||||
'clip_memory_types': {},
|
||||
},
|
||||
};
|
||||
|
||||
// Generate clip positive embedding
|
||||
const String clipPositiveQuery =
|
||||
'Photo of a precious and nostalgic memory radiating warmth, vibrant energy, or quiet beauty — alive with color, light, or emotion';
|
||||
final clipPositiveVector =
|
||||
await MLComputer.instance.runClipText(clipPositiveQuery);
|
||||
embeddingsData['embeddings']['clip_positive'] = {
|
||||
'prompt': clipPositiveQuery,
|
||||
'vector': clipPositiveVector,
|
||||
};
|
||||
|
||||
// Generate people activity embeddings
|
||||
final peopleActivities = <String, dynamic>{};
|
||||
for (final activity in PeopleActivity.values) {
|
||||
final activityName = activity.toString().split('.').last;
|
||||
final prompt = activityQuery(activity);
|
||||
final vector = await MLComputer.instance.runClipText(prompt);
|
||||
peopleActivities[activityName] = {
|
||||
'prompt': prompt,
|
||||
'vector': vector,
|
||||
};
|
||||
}
|
||||
embeddingsData['embeddings']['people_activities'] = peopleActivities;
|
||||
|
||||
// Generate clip memory type embeddings
|
||||
final clipMemoryTypes = <String, dynamic>{};
|
||||
for (final memoryType in ClipMemoryType.values) {
|
||||
final typeName = memoryType.toString().split('.').last;
|
||||
final prompt = clipQuery(memoryType);
|
||||
final vector = await MLComputer.instance.runClipText(prompt);
|
||||
clipMemoryTypes[typeName] = {
|
||||
'prompt': prompt,
|
||||
'vector': vector,
|
||||
};
|
||||
}
|
||||
embeddingsData['embeddings']['clip_memory_types'] = clipMemoryTypes;
|
||||
|
||||
// Convert to JSON and log it
|
||||
final jsonString = const JsonEncoder.withIndent(' ').convert(embeddingsData);
|
||||
dev.log(
|
||||
'_generateAndSaveTextEmbeddings: Generated text embeddings JSON',
|
||||
);
|
||||
|
||||
final tempDir = await getExternalStorageDirectory();
|
||||
final file = File('${tempDir!.path}/text_embeddings.json');
|
||||
await file.writeAsString(jsonString);
|
||||
dev.log(
|
||||
'_generateAndSaveTextEmbeddings: Saved text embeddings to ${file.path}',
|
||||
);
|
||||
|
||||
dev.log(
|
||||
'_generateAndSaveTextEmbeddings: Text embeddings generation complete! Copy the JSON output above to assets/ml/text_embeddings.json',
|
||||
);
|
||||
}
|
||||
@@ -1303,10 +1303,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: in_app_purchase
|
||||
sha256: "5cddd7f463f3bddb1d37a72b95066e840d5822d66291331d7f8f05ce32c24b6c"
|
||||
sha256: "11a40f148eeb4f681a0572003e2b33432e110c90c1bbb4f9ef83b81ec0c4f737"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.3"
|
||||
version: "3.2.1"
|
||||
in_app_purchase_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1327,10 +1327,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: in_app_purchase_storekit
|
||||
sha256: ceddd5a70d268f762d29993ed470054bc2baf8793e41800bc82cde05110260d0
|
||||
sha256: "6ce1361278cacc0481508989ba419b2c9f46a2b0dc54b3fe54f5ee63c2718fef"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.4.2"
|
||||
version: "0.3.22+1"
|
||||
integration_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
@@ -1512,6 +1512,13 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.11"
|
||||
log_viewer:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "../../packages/log_viewer"
|
||||
relative: true
|
||||
source: path
|
||||
version: "1.0.0"
|
||||
logger:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -2140,6 +2147,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.5.0"
|
||||
qr:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: qr
|
||||
sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.2"
|
||||
qr_flutter:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: qr_flutter
|
||||
sha256: "5095f0fc6e3f71d08adef8feccc8cea4f12eec18a2e31c2e8d82cb6019f4b097"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.0"
|
||||
quiver:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -12,7 +12,7 @@ description: ente photos application
|
||||
# Read more about iOS versioning at
|
||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
|
||||
version: 1.2.4+1205
|
||||
version: 1.2.6+1205
|
||||
publish_to: none
|
||||
|
||||
environment:
|
||||
@@ -115,7 +115,7 @@ dependencies:
|
||||
html_unescape: ^2.0.0
|
||||
http: ^1.1.0
|
||||
image: ^4.0.17
|
||||
in_app_purchase: ^3.0.7
|
||||
in_app_purchase: 3.2.1
|
||||
intl: ^0.19.0
|
||||
latlong2: ^0.9.0
|
||||
launcher_icon_switcher: ^0.0.2
|
||||
@@ -123,6 +123,8 @@ dependencies:
|
||||
local_auth: ^2.1.5
|
||||
local_auth_android:
|
||||
local_auth_ios:
|
||||
log_viewer:
|
||||
path: ../../packages/log_viewer
|
||||
logging: ^1.3.0
|
||||
lottie: ^3.3.1
|
||||
maps_launcher: ^3.0.0+1
|
||||
@@ -175,6 +177,7 @@ dependencies:
|
||||
url: https://github.com/ente-io/privacy_screen.git
|
||||
ref: v2-only
|
||||
pro_image_editor: ^6.0.0
|
||||
qr_flutter: ^4.1.0
|
||||
receive_sharing_intent: # pub.dev is behind
|
||||
git:
|
||||
url: https://github.com/KasemJaffer/receive_sharing_intent.git
|
||||
@@ -346,7 +349,6 @@ flutter:
|
||||
- assets/image-editor/
|
||||
- assets/icons/
|
||||
- assets/launcher_icon/
|
||||
- assets/ml/
|
||||
fonts:
|
||||
- family: Inter
|
||||
fonts:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# melos_managed_dependency_overrides: ente_cast,ente_cast_normal,ente_crypto,ente_feature_flag,onnx_dart,ffi,flutter_sodium,intl,js,media_kit,media_kit_libs_ios_video,media_kit_libs_video,media_kit_video,protobuf,video_player,watcher,win32
|
||||
# melos_managed_dependency_overrides: ente_cast,ente_cast_normal,ente_crypto,ente_feature_flag,onnx_dart,ffi,flutter_sodium,intl,js,media_kit,media_kit_libs_ios_video,media_kit_libs_video,media_kit_video,protobuf,video_player,watcher,win32,log_viewer
|
||||
dependency_overrides:
|
||||
ente_cast:
|
||||
path: plugins/ente_cast
|
||||
@@ -41,3 +41,5 @@ dependency_overrides:
|
||||
path: packages/video_player/video_player/
|
||||
watcher: ^1.1.0
|
||||
win32: 5.10.1
|
||||
log_viewer:
|
||||
path: ../../packages/log_viewer
|
||||
|
||||
@@ -1,20 +1,6 @@
|
||||
- Ashil: Changes meant for figuring out thumbnail not loading issue (this change has no scope for regressions or bugs)
|
||||
- Ashil: Add new section (show local ID & config local thumb queue) in debug section in settings to help in debugging thumbnail not loading issue.
|
||||
- Ashil: Revert diskLoadDeferDuration to 80ms (Fixes local thumbnails taking ~1 sec to load on scrolling gallery)
|
||||
- Ashil: Revert diskLoadDeferDuration to 500ms (Was 80ms before but fixes local thumbnail taking very long to load or never loading)
|
||||
- Ashil: Revert increase in cache extent for gallery - to check if thumbnail not loading regression resolves
|
||||
- Similar images design changes. Also changed the vectorDB index file name, so internal users will have another migration (long loading time).
|
||||
- Ashil: New ducky icon in icon switcher
|
||||
- Prateek: Enable immediate manual video stream processing by bypassing user interaction timer
|
||||
- Prateek: Fix multiple concurrent streaming processes bug in ComputeController
|
||||
- Prateek: Fix video streaming description text display spacing in advanced settings
|
||||
- Ashil: Render cached thumbnails faster (noticeable in gallery scrolling)
|
||||
- Similar images UI changes
|
||||
- Neeraj: Fix for double enteries for local file
|
||||
- (prtk) Fix widget initial launch on iOS
|
||||
- Similar images debug screen (Settings > Backup > Free up space > Similar images)
|
||||
- (prtk) Upgrade Flutter version to 3.32.8
|
||||
- (prtk) Run FFMpeg in an isolate
|
||||
- Neeraj: Handle custom domain links
|
||||
- Aman: Fixed bottom nav bar color in light theme, resolved paint editor's initial color, and added tap-to-reset with haptics for tune adjustments (brightness/exposure)
|
||||
- Gracefully handle heic rendering on Android
|
||||
- Neeraj: (i) Add allow joining album option in manage links
|
||||
- Neeraj: Fix lock error + improve logViewer
|
||||
- Laurens: text embedding caching for memories and discover
|
||||
- Neeraj: (i) Option to send qr for link
|
||||
- Neeraj: (i) Debug option to enable logViewer
|
||||
- Neeraj: Potential fix for ios in-app payment
|
||||
|
||||
@@ -1,3 +1 @@
|
||||
- Similar images detection and deletion
|
||||
- Video streaming enhancements
|
||||
- Performance improvements
|
||||
- Bug fixes & performance improvements
|
||||
60
mobile/packages/log_viewer/README.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# Log Viewer
|
||||
|
||||
A Flutter package that provides an in-app log viewer with advanced filtering capabilities for Ente apps.
|
||||
|
||||
## Features
|
||||
|
||||
- 📝 Real-time log capture and display
|
||||
- 🔍 Advanced filtering by logger name, log level, and text search
|
||||
- 🎨 Color-coded log levels for easy identification
|
||||
- 📊 SQLite-based storage with automatic truncation
|
||||
- 📤 Export filtered logs as text
|
||||
- ⚡ Performance optimized with batch inserts and indexing
|
||||
- 🏷️ Optional prefix support for multi-process logging
|
||||
|
||||
## Usage
|
||||
|
||||
### 1. Initialize in your app
|
||||
|
||||
```dart
|
||||
import 'package:log_viewer/log_viewer.dart';
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// Initialize log viewer (basic)
|
||||
await LogViewer.initialize();
|
||||
|
||||
// Or with a prefix for multi-process apps
|
||||
await LogViewer.initialize(prefix: '[MAIN]');
|
||||
|
||||
runApp(MyApp());
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Open the log viewer
|
||||
|
||||
```dart
|
||||
// As a navigation action
|
||||
LogViewer.openViewer(context);
|
||||
|
||||
// Or embed as a widget
|
||||
LogViewer.getViewerPage()
|
||||
```
|
||||
|
||||
### 3. Automatic log capture
|
||||
|
||||
The log viewer automatically registers with `Logger.root.onRecord` to capture all logs from the logging package. No additional setup is required.
|
||||
|
||||
## Filtering Options
|
||||
|
||||
- **Logger Name**: Filter by specific loggers (e.g., "auth", "sync", "ui")
|
||||
- **Log Level**: Filter by severity (SEVERE, WARNING, INFO, etc.)
|
||||
- **Text Search**: Search within log messages and error descriptions
|
||||
- **Time Range**: Filter logs by date/time range
|
||||
|
||||
## Database Management
|
||||
|
||||
- Logs are stored in a local SQLite database
|
||||
- By default, automatic truncation keeps only the most recent 10000 entries
|
||||
- Batch inserts for optimal performance
|
||||