Compare commits

...

51 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

## Tests

Tested in debug mode on my pixel phone.
2025-09-10 18:01:53 +05:30
Laurens Priem
92c4b325ca Merge branch 'main' into text_embeddings_cache 2025-09-10 17:51:51 +05:30
laurenspriem
bc66c1519a put db method preference in claude 2025-09-10 17:49:54 +05:30
Neeraj
1e804d4829 Update internal changes log with new entries 2025-09-10 17:47:03 +05:30
laurenspriem
3a8c95123e Add database method best practice guideline
Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-10 17:42:31 +05:30
laurenspriem
54ad3e4abb Simplify getRepeatedTextEmbeddingCache method
Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-10 17:38:42 +05:30
laurenspriem
8e29a9e26b update internal change 2025-09-10 17:34:03 +05:30
laurenspriem
c691b545a2 Remove unnecessary cache lock from text embeddings service
Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-10 15:39:15 +05:30
laurenspriem
edcec3277e format 2025-09-10 15:37:30 +05:30
laurenspriem
cda3a5b149 Simplify text embeddings cache to use only database cache
Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-10 15:35:58 +05:30
laurenspriem
cc769fdd5b Remove assets folder 2025-09-10 15:20:24 +05:30
laurenspriem
b74fe86e87 Merge branch 'main' into text_embeddings_cache 2025-09-10 15:18:29 +05:30
laurenspriem
e420d7b86f Add text embeddings cache service
Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-10 14:24:08 +05:30
a5xwin
3026ec5be3 fixed bug causing incorrect code count 2025-09-08 16:26:55 +05:30
a5xwin
440bafa56a Merge remote-tracking branch 'origin/main' into multiselect 2025-09-07 15:02:01 +05:30
a5xwin
87c236b629 added multiselect feature 2025-09-07 14:59:53 +05:30
a5xwin
fe732f2778 Merge remote-tracking branch 'origin/main' into autobackup 2025-08-24 22:58:36 +05:30
a5xwin
ca8a067966 added support to view backup path dynamically & improved ui flow 2025-08-24 22:57:35 +05:30
a5xwin
5e3a779925 Merge remote-tracking branch 'origin/main' into autobackup 2025-08-22 19:32:03 +05:30
a5xwin
d1b06abada Improve backup flow: add user controlled path dialog, better default path, and update text casing 2025-08-22 19:21:05 +05:30
a5xwin
9e70dc4312 Merge remote-tracking branch 'origin/main' into autobackup 2025-08-19 15:54:01 +05:30
a5xwin
541d71f65c added encryption feature to backups + refined backup logic 2025-08-19 15:07:23 +05:30
a5xwin
d8fc369a21 Merge remote-tracking branch 'origin/main' into autobackup 2025-08-17 00:15:50 +05:30
a5xwin
8efbebe9c4 Merge remote-tracking branch 'origin/main' into autobackup 2025-08-16 18:36:43 +05:30
a5xwin
a7300b7ac7 Restore local changes from stash 2025-08-15 16:07:51 +05:30
a5xwin
9224cea96f Fixed linting issues 2025-08-14 00:45:11 +05:30
a5xwin
9fbc618d69 Updated pubspec.lock regeneration 2025-08-14 00:02:04 +05:30
a5xwin
4614428f76 Resolved pubspec.lock conflict by taking main version 2025-08-13 23:36:49 +05:30
a5xwin
6fde4ee45f implemented an auto backup feature 2025-08-13 23:25:48 +05:30
48 changed files with 5538 additions and 4424 deletions

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -196,6 +196,11 @@ lib/
- Ensure documentation reflects the current implementation
- Update examples in specs if behavior changes
### 5. Database Methods - BEST PRACTICE
**Prioritize readability in database methods**
- For small result sets (e.g., 1-2 stale entries), prefer filtering in Dart for cleaner, more readable code
- For large datasets, use SQL WHERE clauses for performance - they're much more efficient in SQLite
## Important Notes
- Large service files (some 70k+ lines) - consider file context when editing

File diff suppressed because it is too large Load Diff

View File

@@ -189,6 +189,15 @@ class SuperLogging {
Logger.root.level = kDebugMode ? Level.ALL : Level.INFO;
Logger.root.onRecord.listen(onLogRecord);
if (_preferences.getBool("enable_db_logging") ?? kDebugMode) {
try {
await LogViewer.initialize(prefix: appConfig.prefix);
$.info("Log viewer initialized successfully");
} catch (e) {
$.warning("Failed to initialize log viewer: $e");
}
}
if (isFDroidClient) {
assert(
sentryIsEnabled == false,
@@ -219,19 +228,6 @@ class SuperLogging {
}),
);
// Initialize log viewer integration in debug mode
// Initialize log viewer in debug mode only
if (_preferences.getBool("enable_db_logging") ?? kDebugMode) {
try {
await LogViewer.initialize();
// Register LogViewer with SuperLogging to receive logs with process prefix
LogViewer.registerWithSuperLogging(SuperLogging.registerLogCallback);
$.info("Log viewer initialized successfully");
} catch (e) {
$.warning("Failed to initialize log viewer: $e");
}
}
if (appConfig.body == null) return;
if (enable && sentryIsEnabled) {
@@ -311,17 +307,6 @@ class SuperLogging {
printLog(str);
saveLogString(str, rec.error);
// Hook for external log viewer (if available)
// This allows the log_viewer package to capture logs without creating a dependency
if(_logViewerCallback != null) {
try {
if (_logViewerCallback != null) {
_logViewerCallback!(rec, config.prefix);
}
} catch (_) {
// Silently ignore any errors from the log viewer
}
}
}
static void saveLogString(String str, Object? error) {
@@ -339,15 +324,6 @@ class SuperLogging {
}
}
// Callback that can be set by external packages (like log_viewer)
static void Function(LogRecord, String)? _logViewerCallback;
/// Register a callback to receive log records
/// This is used by the log_viewer package to capture logs
static void registerLogCallback(void Function(LogRecord, String) callback) {
_logViewerCallback = callback;
}
static final Queue<String> fileQueueEntries = Queue();
static bool isFlushing = false;

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
import 'dart:async';
import 'dart:async';
import 'dart:io';
import "package:adaptive_theme/adaptive_theme.dart";

View File

@@ -19,6 +19,7 @@ import "package:photos/services/smart_albums_service.dart";
import "package:photos/services/smart_memories_service.dart";
import "package:photos/services/storage_bonus_service.dart";
import "package:photos/services/sync/trash_sync_service.dart";
import "package:photos/services/text_embeddings_cache_service.dart";
import "package:photos/services/update_service.dart";
import "package:photos/utils/local_settings.dart";
import "package:shared_preferences/shared_preferences.dart";
@@ -136,6 +137,12 @@ SmartMemoriesService get smartMemoriesService {
return _smartMemoriesService!;
}
TextEmbeddingsCacheService? _textEmbeddingsCacheService;
TextEmbeddingsCacheService get textEmbeddingsCacheService {
_textEmbeddingsCacheService ??= TextEmbeddingsCacheService.instance;
return _textEmbeddingsCacheService!;
}
BillingService? _billingService;
BillingService get billingService {
_billingService ??= BillingService(

View File

@@ -193,15 +193,22 @@ class SemanticSearchService {
return results;
}
Future<Map<String, List<int>>> getMatchingFileIDs(
/// Get matching file IDs for common repeated queries like smart memories and magic cache.
/// WARNING: Use this method carefully - it uses persistent caching which is only
/// beneficial for queries that are repeated across app sessions.
/// For regular user searches, use getMatchingFiles instead.
Future<Map<String, List<int>>> getMatchingFileIDsForCommonQueries(
Map<String, double> queryToScore,
) async {
final textEmbeddings = <String, List<double>>{};
final minimumSimilarityMap = <String, double>{};
for (final entry in queryToScore.entries) {
final query = entry.key;
final score = entry.value;
final textEmbedding = await _getTextEmbedding(query);
// Use cache service instead of _getTextEmbedding
final textEmbedding =
await textEmbeddingsCacheService.getEmbedding(query);
textEmbeddings[query] = textEmbedding;
minimumSimilarityMap[query] = score;
}
@@ -210,6 +217,7 @@ class SemanticSearchService {
textEmbeddings,
minimumSimilarityMap: minimumSimilarityMap,
);
final result = <String, List<int>>{};
for (final entry in queryResults.entries) {
final query = entry.key;

View File

@@ -401,8 +401,8 @@ class MagicCacheService {
for (Prompt prompt in magicPromptsData) {
queryToScore[prompt.query] = prompt.minScore;
}
final clipResults =
await SemanticSearchService.instance.getMatchingFileIDs(queryToScore);
final clipResults = await SemanticSearchService.instance
.getMatchingFileIDsForCommonQueries(queryToScore);
for (Prompt prompt in magicPromptsData) {
final List<int> fileUploadedIDs = clipResults[prompt.query] ?? [];
if (fileUploadedIDs.isNotEmpty) {

View File

@@ -37,7 +37,6 @@ import "package:photos/services/location_service.dart";
import "package:photos/services/machine_learning/face_ml/person/person_service.dart";
import "package:photos/services/machine_learning/ml_result.dart";
import "package:photos/services/search_service.dart";
import "package:photos/utils/text_embeddings_util.dart";
class MemoriesResult {
final List<SmartMemory> memories;
@@ -103,18 +102,29 @@ class SmartMemoriesService {
'allImageEmbeddings has ${allImageEmbeddings.length} entries $t',
);
// Load pre-computed text embeddings from assets
final textEmbeddings = await loadTextEmbeddingsFromAssets();
if (textEmbeddings == null) {
_logger.severe('Failed to load pre-computed text embeddings');
throw Exception(
'Failed to load pre-computed text embeddings',
_logger.info('Loading text embeddings via cache service');
final clipPositiveTextVector = Vector.fromList(
await textEmbeddingsCacheService.getEmbedding(
"Photo of a precious and nostalgic memory radiating warmth, vibrant energy, or quiet beauty — alive with color, light, or emotion",
),
);
final clipPeopleActivityVectors = <PeopleActivity, Vector>{};
for (final activity in PeopleActivity.values) {
final query = activityQuery(activity);
clipPeopleActivityVectors[activity] = Vector.fromList(
await textEmbeddingsCacheService.getEmbedding(query),
);
}
_logger.info('Using pre-computed text embeddings from assets');
final clipPositiveTextVector = textEmbeddings.clipPositiveVector;
final clipPeopleActivityVectors = textEmbeddings.peopleActivityVectors;
final clipMemoryTypeVectors = textEmbeddings.clipMemoryTypeVectors;
final clipMemoryTypeVectors = <ClipMemoryType, Vector>{};
for (final memoryType in ClipMemoryType.values) {
final query = clipQuery(memoryType);
clipMemoryTypeVectors[memoryType] = Vector.fromList(
await textEmbeddingsCacheService.getEmbedding(query),
);
}
_logger.info('Text embeddings loaded via cache service');
final local = await getLocale();
final languageCode = local?.languageCode ?? "en";

View File

@@ -0,0 +1,29 @@
import 'package:logging/logging.dart';
import 'package:photos/db/ml/db.dart';
import 'package:photos/services/machine_learning/ml_computer.dart';
class TextEmbeddingsCacheService {
static final _logger = Logger('TextEmbeddingsCacheService');
TextEmbeddingsCacheService._privateConstructor();
static final instance = TextEmbeddingsCacheService._privateConstructor();
Future<List<double>> getEmbedding(String query) async {
// 1. Check database cache
final dbResult =
await MLDataDB.instance.getRepeatedTextEmbeddingCache(query);
if (dbResult != null) {
_logger.info('Text embedding cache hit for query');
return dbResult;
}
// 2. Compute new embedding
_logger.info('Computing new text embedding for query');
final embedding = await MLComputer.instance.runClipText(query);
// 3. Store in database cache
await MLDataDB.instance.putRepeatedTextEmbeddingCache(query, embedding);
return embedding;
}
}

View File

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

View File

@@ -1,172 +0,0 @@
import 'dart:convert';
import "dart:developer" as dev show log;
import "dart:io" show File;
import 'package:flutter/services.dart';
import 'package:logging/logging.dart';
import 'package:ml_linalg/vector.dart';
import "package:path_provider/path_provider.dart"
show getExternalStorageDirectory;
import 'package:photos/models/memories/clip_memory.dart';
import 'package:photos/models/memories/people_memory.dart';
import "package:photos/services/machine_learning/ml_computer.dart"
show MLComputer;
final _logger = Logger('TextEmbeddingsUtil');
/// Loads pre-computed text embeddings from assets
Future<TextEmbeddings?> loadTextEmbeddingsFromAssets() async {
try {
_logger.info('Loading text embeddings from assets');
final jsonString =
await rootBundle.loadString('assets/ml/text_embeddings.json');
final data = json.decode(jsonString) as Map<String, dynamic>;
final embeddings = data['embeddings'] as Map<String, dynamic>;
// Parse clip positive embedding
Vector? clipPositiveVector;
final clipPositive = embeddings['clip_positive'] as Map<String, dynamic>;
final clipPositiveVectorData =
(clipPositive['vector'] as List).cast<double>();
if (clipPositiveVectorData.isNotEmpty) {
clipPositiveVector = Vector.fromList(clipPositiveVectorData);
}
// Parse people activities embeddings
final Map<PeopleActivity, Vector> peopleActivityVectors = {};
final peopleActivities =
embeddings['people_activities'] as Map<String, dynamic>;
for (final activity in PeopleActivity.values) {
final activityName = activity.toString().split('.').last;
if (peopleActivities.containsKey(activityName)) {
final activityData =
peopleActivities[activityName] as Map<String, dynamic>;
final vector = (activityData['vector'] as List).cast<double>();
if (vector.isNotEmpty) {
peopleActivityVectors[activity] = Vector.fromList(vector);
}
}
}
// Parse clip memory types embeddings
final Map<ClipMemoryType, Vector> clipMemoryTypeVectors = {};
final clipMemoryTypes =
embeddings['clip_memory_types'] as Map<String, dynamic>;
for (final memoryType in ClipMemoryType.values) {
final typeName = memoryType.toString().split('.').last;
if (clipMemoryTypes.containsKey(typeName)) {
final typeData = clipMemoryTypes[typeName] as Map<String, dynamic>;
final vector = (typeData['vector'] as List).cast<double>();
if (vector.isNotEmpty) {
clipMemoryTypeVectors[memoryType] = Vector.fromList(vector);
}
}
}
// Check if we have all required embeddings
if (clipPositiveVector == null) {
_logger.severe('Clip positive vector is missing');
throw Exception('Clip positive vector is missing');
}
if (peopleActivityVectors.length != PeopleActivity.values.length) {
_logger.severe('Some people activity vectors are missing');
throw Exception('Some people activity vectors are missing');
}
if (clipMemoryTypeVectors.length != ClipMemoryType.values.length) {
_logger.severe('Some clip memory type vectors are missing');
throw Exception('Some clip memory type vectors are missing');
}
_logger.info('Text embeddings loaded successfully from JSON assets');
return TextEmbeddings(
clipPositiveVector: clipPositiveVector,
peopleActivityVectors: peopleActivityVectors,
clipMemoryTypeVectors: clipMemoryTypeVectors,
);
} catch (e, stackTrace) {
_logger.severe('Failed to load text embeddings from JSON', e, stackTrace);
return null;
}
}
class TextEmbeddings {
final Vector clipPositiveVector;
final Map<PeopleActivity, Vector> peopleActivityVectors;
final Map<ClipMemoryType, Vector> clipMemoryTypeVectors;
const TextEmbeddings({
required this.clipPositiveVector,
required this.peopleActivityVectors,
required this.clipMemoryTypeVectors,
});
}
/// Helper function to generate text embeddings and save them to a JSON file
/// Run this once to generate the embeddings, then copy the output
/// to assets/ml/text_embeddings.json
Future<void> generateAndSaveTextEmbeddings() async {
final Map<String, dynamic> embeddingsData = {
'version': '1.0.0',
'embeddings': {
'clip_positive': {},
'people_activities': {},
'clip_memory_types': {},
},
};
// Generate clip positive embedding
const String clipPositiveQuery =
'Photo of a precious and nostalgic memory radiating warmth, vibrant energy, or quiet beauty — alive with color, light, or emotion';
final clipPositiveVector =
await MLComputer.instance.runClipText(clipPositiveQuery);
embeddingsData['embeddings']['clip_positive'] = {
'prompt': clipPositiveQuery,
'vector': clipPositiveVector,
};
// Generate people activity embeddings
final peopleActivities = <String, dynamic>{};
for (final activity in PeopleActivity.values) {
final activityName = activity.toString().split('.').last;
final prompt = activityQuery(activity);
final vector = await MLComputer.instance.runClipText(prompt);
peopleActivities[activityName] = {
'prompt': prompt,
'vector': vector,
};
}
embeddingsData['embeddings']['people_activities'] = peopleActivities;
// Generate clip memory type embeddings
final clipMemoryTypes = <String, dynamic>{};
for (final memoryType in ClipMemoryType.values) {
final typeName = memoryType.toString().split('.').last;
final prompt = clipQuery(memoryType);
final vector = await MLComputer.instance.runClipText(prompt);
clipMemoryTypes[typeName] = {
'prompt': prompt,
'vector': vector,
};
}
embeddingsData['embeddings']['clip_memory_types'] = clipMemoryTypes;
// Convert to JSON and log it
final jsonString = const JsonEncoder.withIndent(' ').convert(embeddingsData);
dev.log(
'_generateAndSaveTextEmbeddings: Generated text embeddings JSON',
);
final tempDir = await getExternalStorageDirectory();
final file = File('${tempDir!.path}/text_embeddings.json');
await file.writeAsString(jsonString);
dev.log(
'_generateAndSaveTextEmbeddings: Saved text embeddings to ${file.path}',
);
dev.log(
'_generateAndSaveTextEmbeddings: Text embeddings generation complete! Copy the JSON output above to assets/ml/text_embeddings.json',
);
}

View File

@@ -2147,6 +2147,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.5.0"
qr:
dependency: transitive
description:
name: qr
sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
qr_flutter:
dependency: "direct main"
description:
name: qr_flutter
sha256: "5095f0fc6e3f71d08adef8feccc8cea4f12eec18a2e31c2e8d82cb6019f4b097"
url: "https://pub.dev"
source: hosted
version: "4.1.0"
quiver:
dependency: transitive
description:

View File

@@ -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:
@@ -349,7 +349,6 @@ flutter:
- assets/image-editor/
- assets/icons/
- assets/launcher_icon/
- assets/ml/
fonts:
- family: Inter
fonts:

View File

@@ -1,2 +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: (i) Debug option to enable logViewer

View File

@@ -10,6 +10,7 @@ A Flutter package that provides an in-app log viewer with advanced filtering cap
- 📊 SQLite-based storage with automatic truncation
- 📤 Export filtered logs as text
- ⚡ Performance optimized with batch inserts and indexing
- 🏷️ Optional prefix support for multi-process logging
## Usage
@@ -21,9 +22,12 @@ import 'package:log_viewer/log_viewer.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Initialize log viewer
// Initialize log viewer (basic)
await LogViewer.initialize();
// Or with a prefix for multi-process apps
await LogViewer.initialize(prefix: '[MAIN]');
runApp(MyApp());
}
```
@@ -38,9 +42,9 @@ LogViewer.openViewer(context);
LogViewer.getViewerPage()
```
### 3. The log viewer will automatically capture all logs
### 3. Automatic log capture
The package integrates with the Ente logging system to automatically capture and store logs.
The log viewer automatically registers with `Logger.root.onRecord` to capture all logs from the logging package. No additional setup is required.
## Filtering Options

View File

@@ -14,20 +14,13 @@ import 'package:logging/logging.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Initialize the log viewer with custom configuration
await LogViewer.initialize(
maxEntries: 5000, // Optional: default is 10000
);
// Initialize the log viewer
await LogViewer.initialize();
// Set up logging
Logger.root.level = Level.ALL;
Logger.root.onRecord.listen((record) {
// Send logs to log viewer
LogViewer.addLog(record);
// Also print to console
print('${record.level.name}: ${record.time}: ${record.message}');
});
// Log viewer automatically captures all logs - no manual setup needed!
runApp(MyApp());
}
@@ -60,11 +53,7 @@ class _MyHomePageState extends State<MyHomePage> {
icon: Icon(Icons.bug_report),
onPressed: () {
// Navigate to log viewer
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const LogViewerPage(),
),
);
LogViewer.openViewer(context);
},
),
],
@@ -141,14 +130,10 @@ class SuperLogging {
WidgetsFlutterBinding.ensureInitialized();
// Initialize log viewer in debug mode only
// Initialize log viewer in debug mode with prefix
if (kDebugMode) {
try {
await LogViewer.initialize();
// Register LogViewer with SuperLogging to receive logs with process prefix
LogViewer.registerWithSuperLogging(SuperLogging.registerLogCallback);
await LogViewer.initialize(prefix: appConfig.prefix);
_logger.info("Log viewer initialized successfully");
} catch (e) {
_logger.warning("Failed to initialize log viewer: $e");
@@ -171,23 +156,7 @@ class SuperLogging {
print(str);
}
// Send to log viewer callback if registered
try {
if (_logViewerCallback != null) {
_logViewerCallback!(rec, config.prefix);
}
} catch (_) {
// Silently ignore any errors from the log viewer
}
}
// Callback that can be set by external packages (like log_viewer)
static void Function(LogRecord, String)? _logViewerCallback;
/// Register a callback to receive log records
/// This is used by the log_viewer package to capture logs
static void registerLogCallback(void Function(LogRecord, String) callback) {
_logViewerCallback = callback;
// Log viewer automatically captures all logs - no manual integration needed!
}
}
@@ -209,7 +178,7 @@ Future<void> main() async {
### Ente Photos Integration Example
In your Ente Photos app's main.dart, add the log viewer initialization in the `runWithLogs` function:
In your Ente Photos app's main.dart or SuperLogging class, add the log viewer initialization:
```dart
Future runWithLogs(Function() function, {String prefix = ""}) async {
@@ -224,19 +193,16 @@ Future runWithLogs(Function() function, {String prefix = ""}) async {
prefix: prefix,
),
);
// Initialize log viewer in debug mode only
if (kDebugMode) {
try {
await LogViewer.initialize();
// Register LogViewer with SuperLogging to receive logs with process prefix
LogViewer.registerWithSuperLogging(SuperLogging.registerLogCallback);
_logger.info("Log viewer initialized successfully");
} catch (e) {
_logger.warning("Failed to initialize log viewer: $e");
}
}
// In SuperLogging.main():
if (kDebugMode) {
try {
// Simply initialize with prefix - no callbacks needed!
await LogViewer.initialize(prefix: appConfig.prefix);
_logger.info("Log viewer initialized successfully");
} catch (e) {
_logger.warning("Failed to initialize log viewer: $e");
}
}
```
@@ -268,11 +234,7 @@ class SettingsPage extends StatelessWidget {
if (kDebugMode)
GestureDetector(
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const LogViewerPage(),
),
);
LogViewer.openViewer(context);
},
child: Container(
padding: const EdgeInsets.all(8),
@@ -307,17 +269,19 @@ Once integrated, users will have access to:
## How It Works
1. The `log_viewer` package listens to all logs via `Logger.root.onRecord`
2. Logs are stored in a local SQLite database (auto-truncated to 2000 entries)
1. The `log_viewer` package automatically registers with `Logger.root.onRecord` on initialization
2. Logs are stored in a local SQLite database (auto-truncated to 10000 entries by default)
3. The UI provides filtering and search capabilities
4. The integration with `super_logging` is automatic - no changes needed
4. When a prefix is provided, it's automatically prepended to all log messages
5. No manual callback registration or integration needed - just initialize and go!
## Troubleshooting
If logs aren't appearing:
1. Ensure `LogViewer.initialize()` is called after logging is set up
1. Ensure `LogViewer.initialize()` is called early in app initialization
2. Check that the app has write permissions for the database
3. Verify that `Logger.root.level` is set appropriately (not OFF)
4. If using a prefix, verify it's being passed correctly to `LogViewer.initialize(prefix: yourPrefix)`
## Performance Notes

View File

@@ -0,0 +1,40 @@
# Logger Filter Feature Usage
The log viewer now supports filtering logs by logger names directly through the search box, without any UI changes.
## Search Syntax
### Basic Logger Filtering
- `logger:AuthService` - Shows only logs from the AuthService logger
- `logger:UserService` - Shows only logs from the UserService logger
### Wildcard Support
- `logger:Auth*` - Shows logs from all loggers starting with "Auth" (e.g., AuthService, Authentication, AuthManager)
- `logger:*Service` - Not supported yet (only prefix wildcards are supported)
### Combined Search
- `logger:AuthService error` - Shows logs from AuthService that contain "error" in the message
- `login logger:UserService` - Shows logs from UserService that contain "login"
- `logger:Auth* failed` - Shows logs from loggers starting with "Auth" that contain "failed"
## Quick Access from Analytics
1. Navigate to Logger Analytics (via the menu in the log viewer)
2. Tap on any logger name card
3. The log viewer will automatically populate the search box with `logger:LoggerName` and filter the logs
## Implementation Details
- The search box hint text now shows "Search logs or use logger:name..."
- When logger: syntax is detected, it's parsed and converted to logger filters
- The remaining text (after removing logger: patterns) is used for message search
- Multiple logger patterns can be used: `logger:Auth* logger:User*`
- Clearing the search box removes all filters
## Benefits
1. **No UI Changes**: The existing search box is enhanced with new functionality
2. **Intuitive Syntax**: Similar to GitHub and Google search operators
3. **Quick Navigation**: Tap logger names in analytics to instantly filter
4. **Powerful Combinations**: Mix logger filters with text search
5. **Wildcard Support**: Filter multiple related loggers with prefix patterns

View File

@@ -16,12 +16,15 @@ export 'src/ui/log_viewer_page.dart';
/// Main entry point for the log viewer functionality
class LogViewer {
static bool _initialized = false;
static String _prefix = '';
/// Initialize the log viewer
/// This should be called once during app startup
static Future<void> initialize() async {
static Future<void> initialize({String prefix = ''}) async {
if (_initialized) return;
_prefix = prefix;
// Initialize the log store
await LogStore.instance.initialize();
@@ -38,7 +41,7 @@ class LogViewer {
// This will be called dynamically by the main app if SuperLogging is available
// For now, fallback to direct logger listening without prefix
log.Logger.root.onRecord.listen((record) {
LogStore.addLogRecord(record, '');
LogStore.addLogRecord(record, _prefix);
});
} catch (e) {
// SuperLogging not available, fallback to direct logger
@@ -48,24 +51,12 @@ class LogViewer {
}
}
/// Register with SuperLogging callback system (called by main app)
static void registerWithSuperLogging(
void Function(void Function(log.LogRecord, String)) registerCallback,) {
try {
registerCallback((record, processPrefix) {
LogStore.addLogRecord(record, processPrefix);
});
} catch (e) {
// Fallback if registration fails
_registerWithSuperLogging();
}
}
/// Get the log viewer page widget
static Widget getViewerPage() {
if (!_initialized) {
throw StateError(
'LogViewer not initialized. Call LogViewer.initialize() first.',);
'LogViewer not initialized. Call LogViewer.initialize() first.',
);
}
return const LogViewerPage();
}

View File

@@ -10,7 +10,7 @@ class LogDatabase {
static const String _databaseName = 'log_viewer.db';
static const String _tableName = 'logs';
static const int _databaseVersion = 1;
final int maxEntries;
Database? _database;
@@ -56,7 +56,6 @@ class LogDatabase {
);
}
/// Called when database is opened
Future<void> _onOpen(Database db) async {
// Enable write-ahead logging for better performance
@@ -275,14 +274,17 @@ class LogDatabase {
final toDelete = count - maxEntries;
// Delete oldest entries
await db.execute('''
await db.execute(
'''
DELETE FROM $_tableName
WHERE id IN (
SELECT id FROM $_tableName
ORDER BY timestamp ASC
LIMIT ?
)
''', [toDelete],);
''',
[toDelete],
);
}
}
@@ -358,11 +360,13 @@ class LogDatabase {
final results = await db.rawQuery(statsQuery, args);
return results
.map((row) => LoggerStatistic(
loggerName: row['logger_name'] as String,
logCount: row['count'] as int,
percentage: row['percentage'] as double,
),)
.map(
(row) => LoggerStatistic(
loggerName: row['logger_name'] as String,
logCount: row['count'] as int,
percentage: row['percentage'] as double,
),
)
.toList();
}
@@ -378,8 +382,12 @@ class LogDatabase {
if (result.isNotEmpty && result.first['min_timestamp'] != null) {
return TimeRange(
start: DateTime.fromMillisecondsSinceEpoch(result.first['min_timestamp'] as int),
end: DateTime.fromMillisecondsSinceEpoch(result.first['max_timestamp'] as int),
start: DateTime.fromMillisecondsSinceEpoch(
result.first['min_timestamp'] as int,
),
end: DateTime.fromMillisecondsSinceEpoch(
result.first['max_timestamp'] as int,
),
);
}
@@ -396,7 +404,11 @@ class LogDatabase {
''');
return result
.map((row) => DateTime.fromMillisecondsSinceEpoch(row['timestamp'] as int))
.map(
(row) => DateTime.fromMillisecondsSinceEpoch(
row['timestamp'] as int,
),
)
.toList();
}

View File

@@ -17,7 +17,7 @@ class LogStore {
// Buffer for batch inserts - optimized for small entries
final List<LogEntry> _buffer = [];
Timer? _flushTimer;
static const int _bufferSize = 10;
static const int _bufferSize = 10;
static const int _maxBufferSize = 200; // Safety limit
bool _initialized = false;
@@ -83,10 +83,12 @@ class LogStore {
_buffer.clear();
// Use non-blocking database insert for better write performance
unawaited(_database.insertLogs(toInsert).catchError((e) {
// ignore: avoid_print
print('Failed to insert logs to database: $e');
}),);
unawaited(
_database.insertLogs(toInsert).catchError((e) {
// ignore: avoid_print
print('Failed to insert logs to database: $e');
}),
);
}
/// Get logs with filtering
@@ -187,14 +189,16 @@ class LogStore {
final logs = await getLogs(filter: filter, limit: 10000);
final jsonLogs = logs
.map((log) => {
'timestamp': log.timestamp.toIso8601String(),
'level': log.level,
'logger': log.loggerName,
'message': log.message,
if (log.error != null) 'error': log.error,
if (log.stackTrace != null) 'stackTrace': log.stackTrace,
},)
.map(
(log) => {
'timestamp': log.timestamp.toIso8601String(),
'level': log.level,
'logger': log.loggerName,
'message': log.message,
if (log.error != null) 'error': log.error,
if (log.stackTrace != null) 'stackTrace': log.stackTrace,
},
)
.toList();
// Manual JSON formatting for readability

View File

@@ -55,7 +55,6 @@ class _LogFilterDialogState extends State<LogFilterDialog> {
});
}
Widget _buildLevelChip(String level) {
final isSelected = _selectedLevels.contains(level);
final color = LogEntry(
@@ -69,13 +68,20 @@ class _LogFilterDialogState extends State<LogFilterDialog> {
label: Text(
level,
style: TextStyle(
color: isSelected ? Colors.white : null,
fontSize: 12,
color: isSelected ? Colors.white : color,
fontSize: 9,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
),
),
selected: isSelected,
selectedColor: color,
backgroundColor: color.withValues(alpha: 0.15),
checkmarkColor: Colors.white,
side: BorderSide(
color: isSelected ? color : color.withValues(alpha: 0.3),
width: isSelected ? 1.5 : 1,
),
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
onSelected: (selected) {
setState(() {
if (selected) {
@@ -93,18 +99,22 @@ class _LogFilterDialogState extends State<LogFilterDialog> {
final theme = Theme.of(context);
return Dialog(
shape: RoundedRectangleBorder(
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),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Header
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: theme.primaryColor.withValues(alpha: 0.1),
borderRadius:
const BorderRadius.vertical(top: Radius.circular(4)),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: const BoxDecoration(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
@@ -112,12 +122,15 @@ class _LogFilterDialogState extends State<LogFilterDialog> {
Text(
'Filter Logs',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
fontWeight: FontWeight.w600,
fontSize: 18,
),
),
IconButton(
icon: const Icon(Icons.close),
icon: const Icon(Icons.close, size: 22),
onPressed: () => Navigator.pop(context),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
],
),
@@ -126,7 +139,8 @@ class _LogFilterDialogState extends State<LogFilterDialog> {
// Content
Flexible(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -134,122 +148,221 @@ class _LogFilterDialogState extends State<LogFilterDialog> {
Text(
'Log Levels',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
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: 24),
const SizedBox(height: 16),
// Loggers
if (widget.availableLoggers.isNotEmpty) ...[
Text(
'Loggers',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Container(
constraints: const BoxConstraints(maxHeight: 150),
decoration: BoxDecoration(
border: Border.all(color: theme.dividerColor),
borderRadius: BorderRadius.circular(4),
),
child: ListView.builder(
shrinkWrap: true,
itemCount: widget.availableLoggers.length,
itemBuilder: (context, index) {
final logger = widget.availableLoggers[index];
return CheckboxListTile(
title: Text(
logger,
style: const TextStyle(fontSize: 14),
),
value: _selectedLoggers.contains(logger),
dense: true,
onChanged: (selected) {
setState(() {
if (selected == true) {
_selectedLoggers.add(logger);
} else {
_selectedLoggers.remove(logger);
}
});
},
);
},
),
),
const SizedBox(height: 24),
],
// Processes
// Process Prefixes
if (widget.availableProcesses.isNotEmpty) ...[
Text(
'Processes',
'Process',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
fontWeight: FontWeight.w600,
fontSize: 15,
),
),
const SizedBox(height: 8),
Container(
constraints: const BoxConstraints(maxHeight: 120),
decoration: BoxDecoration(
border: Border.all(color: theme.dividerColor),
borderRadius: BorderRadius.circular(4),
border: Border.all(
color: theme.dividerColor.withValues(alpha: 0.5),
width: 1,
),
borderRadius: BorderRadius.circular(6),
color: theme.cardColor,
),
child: ListView.builder(
shrinkWrap: true,
itemCount: widget.availableProcesses.length,
itemBuilder: (context, index) {
final process = widget.availableProcesses[index];
final displayName = LogEntry(
message: '',
level: 'INFO',
timestamp: DateTime.now(),
loggerName: '',
processPrefix: process,
).processDisplayName;
return CheckboxListTile(
title: Text(
displayName,
style: const TextStyle(fontSize: 14),
),
subtitle: process.isNotEmpty
? Text(
'Prefix: $process',
style: TextStyle(
fontSize: 12,
color: theme.textTheme.bodySmall?.color,
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,
),
),
)
: null,
value: _selectedProcesses.contains(process),
dense: true,
onChanged: (selected) {
setState(() {
if (selected == true) {
_selectedProcesses.add(process);
} else {
_selectedProcesses.remove(process);
}
});
},
);
},
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: 24),
const SizedBox(height: 16),
],
// Loggers
if (widget.availableLoggers.isNotEmpty) ...[
Text(
'Loggers',
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.availableLoggers.length,
separatorBuilder: (context, index) => Divider(
height: 1,
thickness: 0.5,
color: theme.dividerColor.withValues(alpha: 0.3),
),
itemBuilder: (context, index) {
final logger = widget.availableLoggers[index];
final isSelected =
_selectedLoggers.contains(logger);
return InkWell(
onTap: () {
setState(() {
if (isSelected) {
_selectedLoggers.remove(logger);
} else {
_selectedLoggers.add(logger);
}
});
},
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
child: Row(
children: [
Expanded(
child: Text(
logger,
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) {
_selectedLoggers.add(logger);
} else {
_selectedLoggers.remove(logger);
}
});
},
materialTapTargetSize:
MaterialTapTargetSize.shrinkWrap,
),
),
],
),
),
);
},
),
),
),
const SizedBox(height: 16),
],
],
),
),
@@ -257,29 +370,71 @@ class _LogFilterDialogState extends State<LogFilterDialog> {
// Actions
Container(
padding: const EdgeInsets.all(16),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: theme.cardColor,
border: Border(
top: BorderSide(
color: theme.dividerColor.withValues(alpha: 0.2),
width: 1,
),
),
borderRadius:
const BorderRadius.vertical(bottom: Radius.circular(4)),
const BorderRadius.vertical(bottom: Radius.circular(16)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
TextButton(
onPressed: _clearFilters,
child: const Text('Clear All'),
style: TextButton.styleFrom(
foregroundColor: theme.colorScheme.error,
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
),
child: const Text(
'Clear All',
style: TextStyle(fontSize: 14),
),
),
Row(
children: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
),
child: Text(
'Cancel',
style: TextStyle(
fontSize: 14,
color: theme.textTheme.bodyLarge?.color,
),
),
),
const SizedBox(width: 8),
ElevatedButton(
FilledButton(
onPressed: _applyFilters,
child: const Text('Apply'),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 8,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: const Text(
'Apply',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
),
],
),

View File

@@ -25,7 +25,9 @@ class _LogViewerPageState extends State<LogViewerPage> {
List<LogEntry> _logs = [];
List<String> _availableLoggers = [];
List<String> _availableProcesses = [];
LogFilter _filter = const LogFilter();
LogFilter _filter = const LogFilter(
selectedLevels: {'WARNING', 'SEVERE', 'SHOUT'},
);
bool _isLoading = true;
bool _isLoadingMore = false;
bool _hasMoreLogs = true;
@@ -35,7 +37,7 @@ class _LogViewerPageState extends State<LogViewerPage> {
// Time filtering state
bool _timeFilterEnabled = false;
// Timeline state
DateTime? _overallStartTime;
DateTime? _overallEndTime;
@@ -178,10 +180,55 @@ class _LogViewerPageState extends State<LogViewerPage> {
}
void _onSearchChanged(String query) {
// Parse query for special syntax like "logger:SomeName"
String? searchText = query;
Set<String>? loggerFilters;
if (query.isNotEmpty) {
// Regular expression to match logger:name patterns
final loggerPattern = RegExp(r'logger:(\S+)');
final matches = loggerPattern.allMatches(query);
if (matches.isNotEmpty) {
loggerFilters = {};
for (final match in matches) {
final loggerName = match.group(1);
if (loggerName != null) {
// Support wildcards (e.g., Auth* matches AuthService, Authentication, etc.)
if (loggerName.endsWith('*')) {
final prefix = loggerName.substring(0, loggerName.length - 1);
// Find all loggers that start with this prefix
for (final logger in _availableLoggers) {
if (logger.startsWith(prefix)) {
loggerFilters.add(logger);
}
}
} else {
loggerFilters.add(loggerName);
}
}
}
// Remove logger:name patterns from search text
searchText = query.replaceAll(loggerPattern, '').trim();
if (searchText.isEmpty) {
searchText = null;
}
}
} else {
// Clear logger filters when search is empty
loggerFilters = {};
}
setState(() {
// Only update logger filters if logger: syntax was found or query is empty
final newLoggerFilters = loggerFilters ??
(query.isEmpty ? <String>{} : _filter.selectedLoggers);
_filter = _filter.copyWith(
searchQuery: query.isEmpty ? null : query,
searchQuery: searchText,
clearSearchQuery: query.isEmpty,
selectedLoggers: newLoggerFilters,
);
});
_loadLogs();
@@ -189,7 +236,9 @@ class _LogViewerPageState extends State<LogViewerPage> {
void _updateTimeFilter() {
setState(() {
if (_timeFilterEnabled && _timelineStartTime != null && _timelineEndTime != null) {
if (_timeFilterEnabled &&
_timelineStartTime != null &&
_timelineEndTime != null) {
_filter = _filter.copyWith(
startTime: _timelineStartTime,
endTime: _timelineEndTime,
@@ -264,7 +313,7 @@ class _LogViewerPageState extends State<LogViewerPage> {
Future<void> _exportLogs() async {
try {
final logText = await _logStore.exportLogs(filter: _filter);
await Share.share(logText, subject: 'App Logs');
} catch (e) {
if (mounted) {
@@ -284,13 +333,19 @@ class _LogViewerPageState extends State<LogViewerPage> {
_loadLogs();
}
void _showAnalytics() {
Navigator.push(
void _showAnalytics() async {
final result = await Navigator.push<String>(
context,
MaterialPageRoute(
builder: (context) => LoggerStatisticsPage(filter: _filter),
),
);
// If a logger filter was returned, apply it to the search box
if (result != null && mounted) {
_searchController.text = result;
_onSearchChanged(result);
}
}
void _showLogDetail(LogEntry log) {
@@ -302,7 +357,6 @@ class _LogViewerPageState extends State<LogViewerPage> {
);
}
@override
void dispose() {
_searchController.dispose();
@@ -349,9 +403,11 @@ class _LogViewerPageState extends State<LogViewerPage> {
tooltip: 'Filters',
),
IconButton(
icon: Icon(_filter.sortNewestFirst
? Icons.arrow_downward
: Icons.arrow_upward,),
icon: Icon(
_filter.sortNewestFirst
? Icons.arrow_downward
: Icons.arrow_upward,
),
onPressed: _toggleSort,
tooltip: _filter.sortNewestFirst
? 'Sort oldest first'
@@ -406,15 +462,17 @@ class _LogViewerPageState extends State<LogViewerPage> {
// Search bar
Container(
color: theme.appBarTheme.backgroundColor,
padding: const EdgeInsets.all(8.0),
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 6.0),
child: TextField(
controller: _searchController,
style: const TextStyle(fontSize: 14),
decoration: InputDecoration(
hintText: 'Search logs...',
prefixIcon: const Icon(Icons.search),
hintStyle: const TextStyle(fontSize: 14),
prefixIcon: const Icon(Icons.search, size: 20),
suffixIcon: _searchController.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
icon: const Icon(Icons.clear, size: 20),
onPressed: () {
_searchController.clear();
_onSearchChanged('');
@@ -424,7 +482,9 @@ class _LogViewerPageState extends State<LogViewerPage> {
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
contentPadding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
isDense: true,
),
onChanged: _onSearchChanged,
),
@@ -470,7 +530,9 @@ class _LogViewerPageState extends State<LogViewerPage> {
});
_updateTimeFilter();
},
tooltip: _timeFilterEnabled ? 'Disable Timeline Filter' : 'Enable Timeline Filter',
tooltip: _timeFilterEnabled
? 'Disable Timeline Filter'
: 'Enable Timeline Filter',
),
],
),
@@ -496,76 +558,89 @@ class _LogViewerPageState extends State<LogViewerPage> {
scrollDirection: Axis.horizontal,
children: [
if (_filter.selectedLoggers.isNotEmpty)
..._filter.selectedLoggers.map((logger) => Padding(
padding: const EdgeInsets.only(right: 4),
child: Chip(
label: Text(logger,
style: const TextStyle(fontSize: 12),),
deleteIcon: const Icon(Icons.close, size: 16),
onDeleted: () {
setState(() {
final newLoggers =
Set<String>.from(_filter.selectedLoggers);
newLoggers.remove(logger);
_filter = _filter.copyWith(
selectedLoggers: newLoggers,);
});
_loadLogs();
},
..._filter.selectedLoggers.map(
(logger) => Padding(
padding: const EdgeInsets.only(right: 4),
child: Chip(
label: Text(
logger,
style: const TextStyle(fontSize: 12),
),
),),
deleteIcon: const Icon(Icons.close, size: 16),
onDeleted: () {
setState(() {
final newLoggers =
Set<String>.from(_filter.selectedLoggers);
newLoggers.remove(logger);
_filter = _filter.copyWith(
selectedLoggers: newLoggers,
);
});
_loadLogs();
},
),
),
),
if (_filter.selectedLevels.isNotEmpty)
..._filter.selectedLevels.map((level) => Padding(
padding: const EdgeInsets.only(right: 4),
child: Chip(
label: Text(level,
style: const TextStyle(fontSize: 12),),
backgroundColor: LogEntry(
..._filter.selectedLevels.map(
(level) => Padding(
padding: const EdgeInsets.only(right: 4),
child: Chip(
label: Text(
level,
style: const TextStyle(fontSize: 12),
),
backgroundColor: LogEntry(
message: '',
level: level,
timestamp: DateTime.now(),
loggerName: '',
).levelColor.withValues(alpha: 0.2),
deleteIcon: const Icon(Icons.close, size: 16),
onDeleted: () {
setState(() {
final newLevels =
Set<String>.from(_filter.selectedLevels);
newLevels.remove(level);
_filter =
_filter.copyWith(selectedLevels: newLevels);
});
_loadLogs();
},
),
),
),
if (_filter.selectedProcesses.isNotEmpty)
..._filter.selectedProcesses.map(
(process) => Padding(
padding: const EdgeInsets.only(right: 4),
child: Chip(
label: Text(
LogEntry(
message: '',
level: level,
level: 'INFO',
timestamp: DateTime.now(),
loggerName: '',
).levelColor.withValues(alpha: 0.2),
deleteIcon: const Icon(Icons.close, size: 16),
onDeleted: () {
setState(() {
final newLevels =
Set<String>.from(_filter.selectedLevels);
newLevels.remove(level);
_filter =
_filter.copyWith(selectedLevels: newLevels);
});
_loadLogs();
},
processPrefix: process,
).processDisplayName,
style: const TextStyle(fontSize: 12),
),
),),
if (_filter.selectedProcesses.isNotEmpty)
..._filter.selectedProcesses.map((process) => Padding(
padding: const EdgeInsets.only(right: 4),
child: Chip(
label: Text(
LogEntry(
message: '',
level: 'INFO',
timestamp: DateTime.now(),
loggerName: '',
processPrefix: process,
).processDisplayName,
style: const TextStyle(fontSize: 12),),
backgroundColor: Colors.purple.withValues(alpha: 0.2),
deleteIcon: const Icon(Icons.close, size: 16),
onDeleted: () {
setState(() {
final newProcesses =
Set<String>.from(_filter.selectedProcesses);
newProcesses.remove(process);
_filter =
_filter.copyWith(selectedProcesses: newProcesses);
});
_loadLogs();
},
),
),),
backgroundColor: Colors.purple.withValues(alpha: 0.2),
deleteIcon: const Icon(Icons.close, size: 16),
onDeleted: () {
setState(() {
final newProcesses =
Set<String>.from(_filter.selectedProcesses);
newProcesses.remove(process);
_filter = _filter.copyWith(
selectedProcesses: newProcesses,
);
});
_loadLogs();
},
),
),
),
],
),
),
@@ -611,7 +686,8 @@ class _LogViewerPageState extends State<LogViewerPage> {
return const Padding(
padding: EdgeInsets.all(16),
child: Center(
child: CircularProgressIndicator(),),
child: CircularProgressIndicator(),
),
);
} else {
// Trigger loading more when reaching the end

View File

@@ -167,64 +167,78 @@ class _LoggerStatisticsPageState extends State<LoggerStatisticsPage> {
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
stat.loggerName,
child: InkWell(
onTap: () {
// Navigate back to log viewer with logger filter in search
Navigator.pop(
context,
'logger:${stat.loggerName}',
);
},
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
stat.loggerName,
style: theme
.textTheme.titleMedium
?.copyWith(
fontWeight: FontWeight.w500,
),
overflow:
TextOverflow.ellipsis,
),
),
Text(
stat.formattedPercentage,
style: theme
.textTheme.titleMedium
?.copyWith(
color: color,
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: LinearProgressIndicator(
value: stat.percentage / 100,
backgroundColor: color
.withValues(alpha: 0.2),
valueColor:
AlwaysStoppedAnimation(
color,
),
minHeight: 6,
),
),
const SizedBox(width: 12),
Text(
'${stat.count} logs',
style: theme
.textTheme.bodyMedium
?.copyWith(
color: theme.colorScheme
.onSurfaceVariant,
fontWeight: FontWeight.w500,
),
overflow: TextOverflow.ellipsis,
),
),
Text(
stat.formattedPercentage,
style: theme.textTheme.titleMedium
?.copyWith(
color: color,
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: LinearProgressIndicator(
value: stat.percentage / 100,
backgroundColor:
color.withValues(alpha: 0.2),
valueColor:
AlwaysStoppedAnimation(
color,),
minHeight: 6,
),
),
const SizedBox(width: 12),
Text(
'${stat.count} logs',
style: theme.textTheme.bodyMedium
?.copyWith(
color: theme.colorScheme
.onSurfaceVariant,
fontWeight: FontWeight.w500,
),
),
],
),
],
],
),
],
),
),
),
);

View File

@@ -27,7 +27,7 @@ class _TimelineWidgetState extends State<TimelineWidget> {
late double _rightPosition;
bool _isDraggingLeft = false;
bool _isDraggingRight = false;
@override
void initState() {
super.initState();
@@ -46,10 +46,13 @@ class _TimelineWidgetState extends State<TimelineWidget> {
}
void _updatePositions() {
final totalDuration = widget.endTime.difference(widget.startTime).inMilliseconds;
final startOffset = widget.currentStart.difference(widget.startTime).inMilliseconds;
final endOffset = widget.currentEnd.difference(widget.startTime).inMilliseconds;
final totalDuration =
widget.endTime.difference(widget.startTime).inMilliseconds;
final startOffset =
widget.currentStart.difference(widget.startTime).inMilliseconds;
final endOffset =
widget.currentEnd.difference(widget.startTime).inMilliseconds;
_leftPosition = startOffset / totalDuration;
_rightPosition = endOffset / totalDuration;
}
@@ -57,11 +60,12 @@ class _TimelineWidgetState extends State<TimelineWidget> {
void _onPanUpdate(DragUpdateDetails details, bool isLeft) {
final RenderBox renderBox = context.findRenderObject() as RenderBox;
final double width = renderBox.size.width - 40; // Account for handle width
// Convert global position to local position within the timeline track
final Offset globalPosition = details.globalPosition;
final Offset localPosition = renderBox.globalToLocal(globalPosition);
final double localX = localPosition.dx - 20; // Account for left handle width
final double localX =
localPosition.dx - 20; // Account for left handle width
final double position = (localX / width).clamp(0.0, 1.0);
setState(() {
@@ -72,17 +76,20 @@ class _TimelineWidgetState extends State<TimelineWidget> {
}
});
final totalDuration = widget.endTime.difference(widget.startTime).inMilliseconds;
final newStart = widget.startTime.add(Duration(milliseconds: (_leftPosition * totalDuration).round()));
final newEnd = widget.startTime.add(Duration(milliseconds: (_rightPosition * totalDuration).round()));
final totalDuration =
widget.endTime.difference(widget.startTime).inMilliseconds;
final newStart = widget.startTime
.add(Duration(milliseconds: (_leftPosition * totalDuration).round()));
final newEnd = widget.startTime
.add(Duration(milliseconds: (_rightPosition * totalDuration).round()));
widget.onTimeRangeChanged(newStart, newEnd);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
height: 120,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
@@ -112,47 +119,57 @@ class _TimelineWidgetState extends State<TimelineWidget> {
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(4),
border: Border.all(
color: theme.colorScheme.outline.withValues(alpha: 0.3),
color: theme.colorScheme.outline
.withValues(alpha: 0.3),
width: 1,
),
),
child: _buildLogDensityIndicator(constraints.maxWidth - 40),
child: _buildLogDensityIndicator(
constraints.maxWidth - 40,
),
),
),
// Selected range
Positioned(
left: 20 + (_leftPosition * (constraints.maxWidth - 40)),
right: constraints.maxWidth - 20 - (_rightPosition * (constraints.maxWidth - 40)),
right: constraints.maxWidth -
20 -
(_rightPosition * (constraints.maxWidth - 40)),
top: 20,
bottom: 20,
child: Container(
decoration: BoxDecoration(
color: theme.colorScheme.primary.withValues(alpha: 0.4),
color:
theme.colorScheme.primary.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(4),
border: Border.all(
color: theme.colorScheme.primary.withValues(alpha: 0.7),
color: theme.colorScheme.primary
.withValues(alpha: 0.7),
width: 1,
),
),
),
),
// Left handle
Positioned(
left: (_leftPosition * (constraints.maxWidth - 40)),
top: 12,
child: GestureDetector(
onPanUpdate: (details) => _onPanUpdate(details, true),
onPanStart: (_) => setState(() => _isDraggingLeft = true),
onPanEnd: (_) => setState(() => _isDraggingLeft = false),
onPanStart: (_) =>
setState(() => _isDraggingLeft = true),
onPanEnd: (_) =>
setState(() => _isDraggingLeft = false),
child: Container(
width: 20,
height: 32,
decoration: BoxDecoration(
color: _isDraggingLeft
? theme.colorScheme.primary
: theme.colorScheme.primary.withValues(alpha: 0.8),
color: _isDraggingLeft
? theme.colorScheme.primary
: theme.colorScheme.primary
.withValues(alpha: 0.8),
borderRadius: BorderRadius.circular(4),
boxShadow: [
BoxShadow(
@@ -170,22 +187,25 @@ class _TimelineWidgetState extends State<TimelineWidget> {
),
),
),
// Right handle
Positioned(
left: (_rightPosition * (constraints.maxWidth - 40)),
top: 12,
child: GestureDetector(
onPanUpdate: (details) => _onPanUpdate(details, false),
onPanStart: (_) => setState(() => _isDraggingRight = true),
onPanEnd: (_) => setState(() => _isDraggingRight = false),
onPanStart: (_) =>
setState(() => _isDraggingRight = true),
onPanEnd: (_) =>
setState(() => _isDraggingRight = false),
child: Container(
width: 20,
height: 32,
decoration: BoxDecoration(
color: _isDraggingRight
? theme.colorScheme.primary
: theme.colorScheme.primary.withValues(alpha: 0.8),
color: _isDraggingRight
? theme.colorScheme.primary
: theme.colorScheme.primary
.withValues(alpha: 0.8),
borderRadius: BorderRadius.circular(4),
boxShadow: [
BoxShadow(
@@ -208,7 +228,7 @@ class _TimelineWidgetState extends State<TimelineWidget> {
},
),
),
// Time labels
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
@@ -230,25 +250,27 @@ class _TimelineWidgetState extends State<TimelineWidget> {
Widget _buildLogDensityIndicator(double width) {
if (widget.logTimestamps.isEmpty) return const SizedBox.shrink();
final theme = Theme.of(context);
final totalDuration = widget.endTime.difference(widget.startTime).inMilliseconds;
final totalDuration =
widget.endTime.difference(widget.startTime).inMilliseconds;
const bucketCount = 50;
final bucketDuration = totalDuration / bucketCount;
final buckets = List<int>.filled(bucketCount, 0);
// Count logs in each bucket
for (final timestamp in widget.logTimestamps) {
final offset = timestamp.difference(widget.startTime).inMilliseconds;
if (offset >= 0 && offset <= totalDuration) {
final bucketIndex = (offset / bucketDuration).floor().clamp(0, bucketCount - 1);
final bucketIndex =
(offset / bucketDuration).floor().clamp(0, bucketCount - 1);
buckets[bucketIndex]++;
}
}
final maxCount = buckets.reduce((a, b) => a > b ? a : b);
if (maxCount == 0) return const SizedBox.shrink();
return Row(
children: buckets.map((count) {
final intensity = count / maxCount;
@@ -257,7 +279,8 @@ class _TimelineWidgetState extends State<TimelineWidget> {
height: double.infinity,
margin: const EdgeInsets.symmetric(horizontal: 0.5),
decoration: BoxDecoration(
color: theme.colorScheme.primary.withValues(alpha: intensity * 0.6),
color:
theme.colorScheme.primary.withValues(alpha: intensity * 0.6),
borderRadius: BorderRadius.circular(1),
),
),
@@ -269,4 +292,4 @@ class _TimelineWidgetState extends State<TimelineWidget> {
String _formatTime(DateTime time) {
return '${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}:${time.second.toString().padLeft(2, '0')}';
}
}
}