Compare commits
14 Commits
autobackup
...
multiselec
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46db03c07b | ||
|
|
b877b0a4cc | ||
|
|
920b3b1931 | ||
|
|
ad95b5bd2d | ||
|
|
6b1757fc36 | ||
|
|
42527c0cd5 | ||
|
|
8810f88236 | ||
|
|
e1423f2030 | ||
|
|
c2ba7c56be | ||
|
|
93618117c5 | ||
|
|
08c38086a5 | ||
|
|
3026ec5be3 | ||
|
|
440bafa56a | ||
|
|
87c236b629 |
167
infra/experiments/logs-viewer/README.md
Normal file
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
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
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
1146
infra/experiments/logs-viewer/script.js
Normal file
File diff suppressed because it is too large
Load Diff
1065
infra/experiments/logs-viewer/styles.css
Normal file
1065
infra/experiments/logs-viewer/styles.css
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
@@ -546,5 +548,9 @@
|
||||
"saveAction":"Save",
|
||||
"saveBackup":"Save backup",
|
||||
"changeLocation": "Change location",
|
||||
"changeCurrentLocation": "Change current 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -7,7 +7,6 @@ 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';
|
||||
//we gonn change
|
||||
|
||||
class LocalBackupService {
|
||||
final _logger = Logger('LocalBackupService');
|
||||
@@ -41,28 +40,7 @@ class LocalBackupService {
|
||||
|
||||
_logger.info("Change detected, triggering automatic encrypted backup...");
|
||||
|
||||
|
||||
String rawContent = await CodeStore.instance.getCodesForExport();
|
||||
|
||||
List<String> lines = rawContent.split('\n');
|
||||
List<String> cleanedLines = [];
|
||||
|
||||
for (String line in lines) {
|
||||
if (line.trim().isEmpty) continue;
|
||||
|
||||
String cleanUrl;
|
||||
if (line.startsWith('"') && line.endsWith('"')) {
|
||||
cleanUrl = jsonDecode(line);
|
||||
}
|
||||
|
||||
else {
|
||||
cleanUrl = line;
|
||||
}
|
||||
|
||||
cleanedLines.add(cleanUrl);
|
||||
}
|
||||
|
||||
final plainTextContent = cleanedLines.join('\n');
|
||||
final plainTextContent = await CodeStore.instance.getCodesForExport();
|
||||
|
||||
if (plainTextContent.trim().isEmpty) {
|
||||
return;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -141,7 +141,6 @@ class CodeStore {
|
||||
bool shouldSync = true,
|
||||
AccountMode? accountMode,
|
||||
List<Code>? existingAllCodes,
|
||||
bool isFrequencyOrRecencyUpdate = false,
|
||||
}) async {
|
||||
|
||||
final mode = accountMode ?? _authenticatorService.getAccountMode();
|
||||
@@ -173,9 +172,6 @@ class CodeStore {
|
||||
shouldSync,
|
||||
mode,
|
||||
);
|
||||
if (!isFrequencyOrRecencyUpdate) {
|
||||
LocalBackupService.instance.triggerAutomaticBackup().ignore();
|
||||
}
|
||||
} else {
|
||||
result = AddResult.newCode;
|
||||
code.generatedID = await _authenticatorService.addEntry(
|
||||
|
||||
@@ -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;
|
||||
@@ -502,7 +516,7 @@ class _CodeWidgetState extends State<CodeWidget> {
|
||||
lastUsedAt: DateTime.now().microsecondsSinceEpoch,
|
||||
),
|
||||
);
|
||||
unawaited(CodeStore.instance.addCode(code, isFrequencyOrRecencyUpdate: true));
|
||||
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
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,
|
||||
@@ -120,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);
|
||||
@@ -421,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;
|
||||
@@ -572,6 +1219,7 @@ class _HomePageState extends State<HomePage> {
|
||||
? TagChipState.selected
|
||||
: TagChipState.unselected,
|
||||
onTap: () {
|
||||
_codeDisplayStore.clearSelection();
|
||||
selectedTag = "";
|
||||
_isTrashOpen = false;
|
||||
|
||||
@@ -588,6 +1236,7 @@ class _HomePageState extends State<HomePage> {
|
||||
? TagChipState.selected
|
||||
: TagChipState.unselected,
|
||||
onTap: () {
|
||||
_codeDisplayStore.clearSelection();
|
||||
selectedTag = "";
|
||||
_isTrashOpen = !_isTrashOpen;
|
||||
setState(() {});
|
||||
@@ -605,6 +1254,7 @@ class _HomePageState extends State<HomePage> {
|
||||
? TagChipState.selected
|
||||
: TagChipState.unselected,
|
||||
onTap: () {
|
||||
_codeDisplayStore.clearSelection();
|
||||
_isTrashOpen = false;
|
||||
if (selectedTag == tags[customTagIndex]) {
|
||||
selectedTag = "";
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -53,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 =
|
||||
@@ -94,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(
|
||||
|
||||
@@ -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.5+1205
|
||||
version: 1.2.6+1205
|
||||
publish_to: none
|
||||
|
||||
environment:
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
- 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
|
||||
- Neeraj: Potential fix for ios in-app payment
|
||||
|
||||
@@ -69,7 +69,7 @@ class _LogFilterDialogState extends State<LogFilterDialog> {
|
||||
level,
|
||||
style: TextStyle(
|
||||
color: isSelected ? Colors.white : color,
|
||||
fontSize: 13,
|
||||
fontSize: 9,
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
|
||||
),
|
||||
),
|
||||
@@ -79,9 +79,9 @@ class _LogFilterDialogState extends State<LogFilterDialog> {
|
||||
checkmarkColor: Colors.white,
|
||||
side: BorderSide(
|
||||
color: isSelected ? color : color.withValues(alpha: 0.3),
|
||||
width: isSelected ? 2 : 1,
|
||||
width: isSelected ? 1.5 : 1,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
||||
onSelected: (selected) {
|
||||
setState(() {
|
||||
if (selected) {
|
||||
@@ -103,7 +103,7 @@ class _LogFilterDialogState extends State<LogFilterDialog> {
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxWidth: 400, maxHeight: 600),
|
||||
constraints: const BoxConstraints(maxWidth: 380, maxHeight: 500),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
@@ -112,7 +112,7 @@ class _LogFilterDialogState extends State<LogFilterDialog> {
|
||||
children: [
|
||||
// Header
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: const BoxDecoration(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||
),
|
||||
@@ -123,7 +123,7 @@ class _LogFilterDialogState extends State<LogFilterDialog> {
|
||||
'Filter Logs',
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 20,
|
||||
fontSize: 18,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
@@ -140,7 +140,7 @@ class _LogFilterDialogState extends State<LogFilterDialog> {
|
||||
Flexible(
|
||||
child: SingleChildScrollView(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
|
||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -149,19 +149,124 @@ class _LogFilterDialogState extends State<LogFilterDialog> {
|
||||
'Log Levels',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 16,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
runSpacing: 3,
|
||||
children: LogLevels.all
|
||||
.where((level) => level != 'ALL' && level != 'OFF')
|
||||
.map(_buildLevelChip)
|
||||
.toList(),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Process Prefixes
|
||||
if (widget.availableProcesses.isNotEmpty) ...[
|
||||
Text(
|
||||
'Process',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 15,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
constraints: const BoxConstraints(maxHeight: 120),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: theme.dividerColor.withValues(alpha: 0.5),
|
||||
width: 1,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
color: theme.cardColor,
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: ListView.separated(
|
||||
shrinkWrap: true,
|
||||
itemCount: widget.availableProcesses.length,
|
||||
separatorBuilder: (context, index) => Divider(
|
||||
height: 1,
|
||||
thickness: 0.5,
|
||||
color: theme.dividerColor.withValues(alpha: 0.3),
|
||||
),
|
||||
itemBuilder: (context, index) {
|
||||
final process = widget.availableProcesses[index];
|
||||
final isSelected =
|
||||
_selectedProcesses.contains(process);
|
||||
final displayName = LogEntry(
|
||||
message: '',
|
||||
level: 'INFO',
|
||||
timestamp: DateTime.now(),
|
||||
loggerName: '',
|
||||
processPrefix: process,
|
||||
).processDisplayName;
|
||||
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
if (isSelected) {
|
||||
_selectedProcesses.remove(process);
|
||||
} else {
|
||||
_selectedProcesses.add(process);
|
||||
}
|
||||
});
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
displayName,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: isSelected
|
||||
? theme.primaryColor
|
||||
: theme
|
||||
.textTheme.bodyLarge?.color,
|
||||
fontWeight: isSelected
|
||||
? FontWeight.w500
|
||||
: FontWeight.normal,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: Checkbox(
|
||||
value: isSelected,
|
||||
onChanged: (selected) {
|
||||
setState(() {
|
||||
if (selected == true) {
|
||||
_selectedProcesses.add(process);
|
||||
} else {
|
||||
_selectedProcesses
|
||||
.remove(process);
|
||||
}
|
||||
});
|
||||
},
|
||||
materialTapTargetSize:
|
||||
MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
|
||||
// Loggers
|
||||
if (widget.availableLoggers.isNotEmpty) ...[
|
||||
@@ -169,22 +274,22 @@ class _LogFilterDialogState extends State<LogFilterDialog> {
|
||||
'Loggers',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 16,
|
||||
fontSize: 15,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
constraints: const BoxConstraints(maxHeight: 180),
|
||||
constraints: const BoxConstraints(maxHeight: 120),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: theme.dividerColor.withValues(alpha: 0.5),
|
||||
width: 1,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
color: theme.cardColor,
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: ListView.separated(
|
||||
shrinkWrap: true,
|
||||
itemCount: widget.availableLoggers.length,
|
||||
@@ -209,8 +314,8 @@ class _LogFilterDialogState extends State<LogFilterDialog> {
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
@@ -218,7 +323,7 @@ class _LogFilterDialogState extends State<LogFilterDialog> {
|
||||
child: Text(
|
||||
logger,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontSize: 13,
|
||||
color: isSelected
|
||||
? theme.primaryColor
|
||||
: theme
|
||||
@@ -231,8 +336,8 @@ class _LogFilterDialogState extends State<LogFilterDialog> {
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: Checkbox(
|
||||
value: isSelected,
|
||||
onChanged: (selected) {
|
||||
@@ -256,7 +361,7 @@ class _LogFilterDialogState extends State<LogFilterDialog> {
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
],
|
||||
),
|
||||
@@ -265,7 +370,7 @@ class _LogFilterDialogState extends State<LogFilterDialog> {
|
||||
|
||||
// Actions
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
@@ -284,8 +389,8 @@ class _LogFilterDialogState extends State<LogFilterDialog> {
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: theme.colorScheme.error,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 10,
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
),
|
||||
child: const Text(
|
||||
@@ -299,8 +404,8 @@ class _LogFilterDialogState extends State<LogFilterDialog> {
|
||||
onPressed: () => Navigator.pop(context),
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 20,
|
||||
vertical: 10,
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
@@ -311,13 +416,13 @@ class _LogFilterDialogState extends State<LogFilterDialog> {
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const SizedBox(width: 8),
|
||||
FilledButton(
|
||||
onPressed: _applyFilters,
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
vertical: 10,
|
||||
horizontal: 20,
|
||||
vertical: 8,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
|
||||
Reference in New Issue
Block a user