Compare commits
3 Commits
multiselec
...
ios_build
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5e681f53e | ||
|
|
2fd1e58b19 | ||
|
|
a5965bcaec |
4
.github/workflows/web-deploy-one.yml
vendored
4
.github/workflows/web-deploy-one.yml
vendored
@@ -29,8 +29,6 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup node and enable yarn caching
|
||||
uses: actions/setup-node@v4
|
||||
@@ -40,7 +38,7 @@ jobs:
|
||||
cache-dependency-path: "web/yarn.lock"
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --frozen-lockfile
|
||||
run: yarn install
|
||||
|
||||
- name: Build ${{ inputs.app }}
|
||||
run: yarn build:${{ inputs.app }}
|
||||
|
||||
4
.github/workflows/web-deploy-preview.yml
vendored
4
.github/workflows/web-deploy-preview.yml
vendored
@@ -29,8 +29,6 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup node and enable yarn caching
|
||||
uses: actions/setup-node@v4
|
||||
@@ -40,7 +38,7 @@ jobs:
|
||||
cache-dependency-path: "web/yarn.lock"
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --frozen-lockfile
|
||||
run: yarn install
|
||||
|
||||
- name: Build ${{ inputs.app }}
|
||||
run: yarn build:${{ inputs.app }}
|
||||
|
||||
3
.github/workflows/web-deploy-staging.yml
vendored
3
.github/workflows/web-deploy-staging.yml
vendored
@@ -37,7 +37,6 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ steps.select-branch.outputs.branch }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup node and enable yarn caching
|
||||
uses: actions/setup-node@v4
|
||||
@@ -47,7 +46,7 @@ jobs:
|
||||
cache-dependency-path: "web/yarn.lock"
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --frozen-lockfile
|
||||
run: yarn install
|
||||
|
||||
- name: Build photos
|
||||
run: yarn build:photos
|
||||
|
||||
12
.github/workflows/web-deploy.yml
vendored
12
.github/workflows/web-deploy.yml
vendored
@@ -33,8 +33,6 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup node and enable yarn caching
|
||||
uses: actions/setup-node@v4
|
||||
@@ -44,15 +42,7 @@ jobs:
|
||||
cache-dependency-path: "web/yarn.lock"
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --frozen-lockfile
|
||||
|
||||
- name: Audit dependencies
|
||||
run: |
|
||||
yarn audit --level critical || exit_code=$?
|
||||
if [[ $exit_code -ge 16 ]]; then
|
||||
echo "::error::Yarn audit found critical issues"
|
||||
exit 1
|
||||
fi
|
||||
run: yarn install
|
||||
|
||||
- name: Build photos
|
||||
run: yarn build:photos
|
||||
|
||||
12
.github/workflows/web-lint.yml
vendored
12
.github/workflows/web-lint.yml
vendored
@@ -24,8 +24,6 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup node and enable yarn caching
|
||||
uses: actions/setup-node@v4
|
||||
@@ -34,14 +32,6 @@ jobs:
|
||||
cache: "yarn"
|
||||
cache-dependency-path: "web/yarn.lock"
|
||||
|
||||
- run: yarn install --frozen-lockfile
|
||||
- run: yarn install
|
||||
|
||||
- run: yarn lint
|
||||
|
||||
- name: Audit dependencies
|
||||
run: |
|
||||
yarn audit --level critical || exit_code=$?
|
||||
if [[ $exit_code -ge 16 ]]; then
|
||||
echo "::error::Yarn audit found critical issues"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -89,7 +89,7 @@ cast.ente.yourdomain.tld {
|
||||
Reload Caddy for changes to take effect.
|
||||
|
||||
```shell
|
||||
sudo systemctl reload caddy
|
||||
sudo systemctl caddy reload
|
||||
```
|
||||
|
||||
## Step 4: Verify the setup
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
# 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
|
||||
@@ -1,110 +0,0 @@
|
||||
/* 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);
|
||||
}
|
||||
}
|
||||
@@ -1,218 +0,0 @@
|
||||
<!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
1
mobile/apps/auth/.gitignore
vendored
1
mobile/apps/auth/.gitignore
vendored
@@ -8,7 +8,6 @@
|
||||
.buildlog/
|
||||
.history
|
||||
.svn/
|
||||
android/app/build/
|
||||
|
||||
# Editors
|
||||
.vscode/
|
||||
|
||||
Submodule mobile/apps/auth/flutter updated: 2663184aa7...edada7c56e
@@ -11,7 +11,6 @@
|
||||
"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",
|
||||
@@ -140,7 +139,6 @@
|
||||
"existingUser": "Existing User",
|
||||
"newUser": "New to Ente",
|
||||
"delete": "Delete",
|
||||
"addTag": "Add tag",
|
||||
"enterYourPasswordHint": "Enter your password",
|
||||
"forgotPassword": "Forgot password",
|
||||
"oops": "Oops",
|
||||
@@ -529,28 +527,5 @@
|
||||
"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.",
|
||||
"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?"
|
||||
"errorGenericBody": "An unexpected error occurred while importing."
|
||||
}
|
||||
@@ -54,14 +54,9 @@ class ViewQrPage extends StatelessWidget {
|
||||
const SizedBox(
|
||||
width: 10,
|
||||
),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Text(
|
||||
code?.account ?? '',
|
||||
style: enteTextTheme.largeBold,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
code?.account ?? '',
|
||||
style: enteTextTheme.largeBold,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -78,14 +73,9 @@ class ViewQrPage extends StatelessWidget {
|
||||
const SizedBox(
|
||||
width: 10,
|
||||
),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Text(
|
||||
code?.issuer ?? '',
|
||||
style: enteTextTheme.largeBold,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
code?.issuer ?? '',
|
||||
style: enteTextTheme.largeBold,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:ente_auth/models/export/ente.dart';
|
||||
import 'package:ente_auth/store/code_store.dart';
|
||||
import 'package:ente_crypto_dart/ente_crypto_dart.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:intl/intl.dart'; //for time based file naming
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class LocalBackupService {
|
||||
final _logger = Logger('LocalBackupService');
|
||||
static final LocalBackupService instance =
|
||||
LocalBackupService._privateConstructor();
|
||||
LocalBackupService._privateConstructor();
|
||||
|
||||
static const int _maxBackups = 2;
|
||||
|
||||
// to create an encrypted backup file if the toggle is on
|
||||
Future<void> triggerAutomaticBackup() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
final isEnabled = prefs.getBool('isAutoBackupEnabled') ?? false;
|
||||
if (!isEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
final backupPath = prefs.getString('autoBackupPath');
|
||||
if (backupPath == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const storage = FlutterSecureStorage();
|
||||
final password = await storage.read(key: 'autoBackupPassword');
|
||||
if (password == null || password.isEmpty) {
|
||||
_logger.warning("Automatic backup skipped: password not set.");
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.info("Change detected, triggering automatic encrypted backup...");
|
||||
|
||||
final plainTextContent = await CodeStore.instance.getCodesForExport();
|
||||
|
||||
if (plainTextContent.trim().isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final kekSalt = CryptoUtil.getSaltToDeriveKey();
|
||||
final derivedKeyResult = await CryptoUtil.deriveSensitiveKey(
|
||||
utf8.encode(password),
|
||||
kekSalt,
|
||||
);
|
||||
|
||||
final encResult = await CryptoUtil.encryptData(
|
||||
utf8.encode(plainTextContent),
|
||||
derivedKeyResult.key,
|
||||
);
|
||||
|
||||
final encContent = CryptoUtil.bin2base64(encResult.encryptedData!);
|
||||
final encNonce = CryptoUtil.bin2base64(encResult.header!);
|
||||
|
||||
final EnteAuthExport data = EnteAuthExport(
|
||||
version: 1,
|
||||
encryptedData: encContent,
|
||||
encryptionNonce: encNonce,
|
||||
kdfParams: KDFParams(
|
||||
memLimit: derivedKeyResult.memLimit,
|
||||
opsLimit: derivedKeyResult.opsLimit,
|
||||
salt: CryptoUtil.bin2base64(kekSalt),
|
||||
),
|
||||
);
|
||||
|
||||
final encryptedJson = jsonEncode(data.toJson());
|
||||
|
||||
final now = DateTime.now();
|
||||
final formatter = DateFormat('yyyy-MM-dd_HH-mm-ss');
|
||||
final formattedDate = formatter.format(now);
|
||||
final fileName = 'ente-auth-auto-backup-$formattedDate.json';
|
||||
|
||||
final filePath = '$backupPath/$fileName';
|
||||
final backupFile = File(filePath);
|
||||
|
||||
await backupFile.writeAsString(encryptedJson);
|
||||
await _manageOldBackups(backupPath);
|
||||
|
||||
_logger.info('Automatic encrypted backup successful! Saved to: $filePath');
|
||||
} catch (e, s) {
|
||||
_logger.severe('Silent error during automatic backup', e, s);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _manageOldBackups(String backupPath) async {
|
||||
try {
|
||||
_logger.info("Checking for old backups to clean up...");
|
||||
final directory = Directory(backupPath);
|
||||
|
||||
// fetch all filenames in the folder, filter out ente backup files
|
||||
final files = directory.listSync()
|
||||
.where((entity) =>
|
||||
entity is File &&
|
||||
entity.path.split('/').last.startsWith('ente-auth-auto-backup-'),)
|
||||
.map((entity) => entity as File)
|
||||
.toList();
|
||||
|
||||
// sort the fetched files in asc order (oldest first because the name is a timestamp)
|
||||
files.sort((a, b) => a.path.compareTo(b.path));
|
||||
|
||||
// if we have more files than our limit, delete the oldest ones (current limit=_maxBackups)
|
||||
while (files.length > _maxBackups) {
|
||||
// remove the oldest file (at index 0) from the list
|
||||
final fileToDelete = files.removeAt(0);
|
||||
// and delete it from the device's storage..
|
||||
await fileToDelete.delete();
|
||||
_logger.info('Deleted old backup: ${fileToDelete.path}');
|
||||
}
|
||||
_logger.info('Backup count is now ${files.length}. Cleanup complete.');
|
||||
} catch (e, s) {
|
||||
_logger.severe('Error during old backup cleanup', e, s);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deleteAllBackupsIn(String path) async {
|
||||
try {
|
||||
_logger.info("Deleting all backups in old location: $path");
|
||||
final directory = Directory(path);
|
||||
|
||||
if (!await directory.exists()) {
|
||||
_logger.warning("Old backup directory not found. Nothing to delete.");
|
||||
return;
|
||||
}
|
||||
|
||||
final files = directory.listSync()
|
||||
.where((entity) =>
|
||||
entity is File &&
|
||||
entity.path.split('/').last.startsWith('ente-auth-auto-backup-'),)
|
||||
.map((entity) => entity as File)
|
||||
.toList();
|
||||
|
||||
if (files.isEmpty) {
|
||||
_logger.info("No old backup files found to delete.");
|
||||
return;
|
||||
}
|
||||
|
||||
for (final file in files) {
|
||||
await file.delete();
|
||||
_logger.info('Deleted: ${file.path}');
|
||||
}
|
||||
_logger.info("Successfully cleaned up old backup location.");
|
||||
|
||||
} catch (e, s) {
|
||||
_logger.severe('Error during full backup cleanup of old directory', e, s);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,31 +14,6 @@ class CodeDisplayStore {
|
||||
|
||||
late CodeStore _codeStore;
|
||||
|
||||
final ValueNotifier<bool> isSelectionModeActive = ValueNotifier(false);
|
||||
final ValueNotifier<Set<String>> selectedCodeIds = ValueNotifier(<String>{});
|
||||
|
||||
// toggles the selection status of a code
|
||||
void toggleSelection(String codeId){
|
||||
final newSelection = Set<String>.from(selectedCodeIds.value);
|
||||
|
||||
if(newSelection.contains(codeId)){
|
||||
newSelection.remove(codeId);
|
||||
}
|
||||
|
||||
else{
|
||||
newSelection.add(codeId);
|
||||
}
|
||||
|
||||
selectedCodeIds.value = newSelection; //if we selected atleast one code, then we're in selection mode.. else: exit selection mode
|
||||
isSelectionModeActive.value = newSelection.isNotEmpty;
|
||||
}
|
||||
|
||||
//method to clear the entire selection
|
||||
void clearSelection(){
|
||||
selectedCodeIds.value = <String>{};
|
||||
isSelectionModeActive.value = false;
|
||||
}
|
||||
|
||||
Future<void> init() async {
|
||||
_codeStore = CodeStore.instance;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ 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';
|
||||
@@ -65,27 +64,6 @@ 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,
|
||||
@@ -117,6 +95,7 @@ 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;
|
||||
@@ -142,15 +121,12 @@ 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;
|
||||
@@ -179,7 +155,6 @@ class CodeStore {
|
||||
shouldSync,
|
||||
mode,
|
||||
);
|
||||
LocalBackupService.instance.triggerAutomaticBackup().ignore();
|
||||
}
|
||||
Bus.instance.fire(CodesUpdatedEvent());
|
||||
return result;
|
||||
@@ -189,7 +164,6 @@ class CodeStore {
|
||||
final mode = accountMode ?? _authenticatorService.getAccountMode();
|
||||
await _authenticatorService.deleteEntry(code.generatedID!, mode);
|
||||
Bus.instance.fire(CodesUpdatedEvent());
|
||||
LocalBackupService.instance.triggerAutomaticBackup().ignore();
|
||||
}
|
||||
|
||||
bool _isOfflineImportRunning = false;
|
||||
@@ -240,6 +214,7 @@ 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,
|
||||
@@ -261,21 +236,10 @@ class CodeStore {
|
||||
_isOfflineImportRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> getCodesForExport() async {
|
||||
final allCodes = await getAllCodes(sortCodes: false);
|
||||
String data = "";
|
||||
for (final code in allCodes) {
|
||||
if (code.hasError) continue;
|
||||
data += "${code.toOTPAuthUrlFormat()}\n";
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
enum AddResult {
|
||||
newCode,
|
||||
duplicate,
|
||||
updateCode,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,10 +10,10 @@ import 'package:ente_auth/models/code.dart';
|
||||
import 'package:ente_auth/onboarding/view/setup_enter_secret_key_page.dart';
|
||||
import 'package:ente_auth/onboarding/view/view_qr_page.dart';
|
||||
import 'package:ente_auth/services/preference_service.dart';
|
||||
import 'package:ente_auth/store/code_display_store.dart';
|
||||
import 'package:ente_auth/store/code_store.dart';
|
||||
import 'package:ente_auth/theme/ente_theme.dart';
|
||||
import 'package:ente_auth/ui/code_timer_progress.dart';
|
||||
import 'package:ente_auth/ui/components/bottom_action_bar_widget.dart';
|
||||
import 'package:ente_auth/ui/components/models/button_type.dart';
|
||||
import 'package:ente_auth/ui/share/code_share.dart';
|
||||
import 'package:ente_auth/ui/utils/icon_utils.dart';
|
||||
@@ -103,6 +103,7 @@ 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;
|
||||
@@ -117,110 +118,96 @@ class _CodeWidgetState extends State<CodeWidget> {
|
||||
}
|
||||
final l10n = context.l10n;
|
||||
|
||||
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,
|
||||
Widget getCardContents(AppLocalizations l10n) {
|
||||
return Stack(
|
||||
children: [
|
||||
if (widget.code.type.isTOTPCompatible)
|
||||
CodeTimerProgress(
|
||||
key: ValueKey('period_${widget.code.period}'),
|
||||
period: widget.code.period,
|
||||
isCompactMode: widget.isCompactMode,
|
||||
timeOffsetInMilliseconds:
|
||||
PreferenceService.instance.timeOffsetInMilliSeconds(),
|
||||
),
|
||||
widget.isCompactMode
|
||||
? const SizedBox(height: 4)
|
||||
: const SizedBox(height: 28),
|
||||
Row(
|
||||
children: [
|
||||
_shouldShowLargeIcon ? _getIcon() : const SizedBox.shrink(),
|
||||
Expanded(
|
||||
child: Column(
|
||||
children: [
|
||||
_getTopRow(isSelected: isSelected),
|
||||
widget.isCompactMode
|
||||
? const SizedBox.shrink()
|
||||
: const SizedBox(height: 4),
|
||||
_getBottomRow(l10n),
|
||||
],
|
||||
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 (widget.code.type.isTOTPCompatible)
|
||||
CodeTimerProgress(
|
||||
key: ValueKey('period_${widget.code.period}'),
|
||||
period: widget.code.period,
|
||||
isCompactMode: widget.isCompactMode,
|
||||
timeOffsetInMilliseconds:
|
||||
PreferenceService.instance.timeOffsetInMilliSeconds(),
|
||||
),
|
||||
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),
|
||||
],
|
||||
),
|
||||
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,
|
||||
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: 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,
|
||||
color: Theme.of(context).colorScheme.codeCardBackgroundColor,
|
||||
boxShadow:
|
||||
(widget.code.isPinned && !isSelected) ? colorScheme.pinnedCardBoxShadow : [],
|
||||
widget.code.isPinned ? colorScheme.pinnedCardBoxShadow : [],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
@@ -228,12 +215,7 @@ Widget clippedCard(AppLocalizations l10n) {
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
onTap: () {
|
||||
final store = CodeDisplayStore.instance;
|
||||
if (store.isSelectionModeActive.value) {
|
||||
store.toggleSelection(widget.code.secret);
|
||||
} else {
|
||||
_copyCurrentOTPToClipboard();
|
||||
}
|
||||
_copyCurrentOTPToClipboard();
|
||||
},
|
||||
onDoubleTap: isMaskingEnabled
|
||||
? () {
|
||||
@@ -247,16 +229,30 @@ Widget clippedCard(AppLocalizations l10n) {
|
||||
onLongPress: widget.isReordering
|
||||
? null
|
||||
: () {
|
||||
CodeDisplayStore.instance.toggleSelection(widget.code.secret);
|
||||
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(),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
child: getCardContents(l10n, isSelected: isSelected),
|
||||
child: getCardContents(l10n),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return Container(
|
||||
margin: widget.isCompactMode
|
||||
@@ -277,7 +273,7 @@ Widget clippedCard(AppLocalizations l10n) {
|
||||
),
|
||||
if (!widget.code.isTrashed)
|
||||
MenuItem(
|
||||
label: context.l10n.qr,
|
||||
label: 'QR',
|
||||
icon: Icons.qr_code_2_outlined,
|
||||
onSelected: () => _onShowQrPressed(null),
|
||||
),
|
||||
@@ -311,7 +307,7 @@ Widget clippedCard(AppLocalizations l10n) {
|
||||
const MenuDivider(),
|
||||
MenuItem(
|
||||
label: widget.code.isTrashed ? l10n.delete : l10n.trash,
|
||||
value: l10n.delete,
|
||||
value: "Delete",
|
||||
icon: widget.code.isTrashed
|
||||
? Icons.delete_forever
|
||||
: Icons.delete,
|
||||
@@ -407,64 +403,54 @@ Widget clippedCard(AppLocalizations l10n) {
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
const SizedBox(width: 8),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
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.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(),
|
||||
],
|
||||
),
|
||||
),
|
||||
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;
|
||||
@@ -492,7 +478,7 @@ Widget clippedCard(AppLocalizations l10n) {
|
||||
_getCurrentOTP(),
|
||||
confirmationMessage: context.l10n.copiedToClipboard,
|
||||
);
|
||||
_updateCodeMetadata().ignore();
|
||||
_udateCodeMetadata().ignore();
|
||||
}
|
||||
|
||||
void _copyNextToClipboard() {
|
||||
@@ -500,10 +486,10 @@ Widget clippedCard(AppLocalizations l10n) {
|
||||
_getNextTotp(),
|
||||
confirmationMessage: context.l10n.copiedNextToClipboard,
|
||||
);
|
||||
_updateCodeMetadata().ignore();
|
||||
_udateCodeMetadata().ignore();
|
||||
}
|
||||
|
||||
Future<void> _updateCodeMetadata() async {
|
||||
Future<void> _udateCodeMetadata() async {
|
||||
if (widget.sortKey == null) return;
|
||||
Future.delayed(const Duration(milliseconds: 100), () {
|
||||
if (mounted) {
|
||||
@@ -516,7 +502,7 @@ Widget clippedCard(AppLocalizations l10n) {
|
||||
lastUsedAt: DateTime.now().microsecondsSinceEpoch,
|
||||
),
|
||||
);
|
||||
unawaited(CodeStore.instance.updateCode(widget.code, code));
|
||||
unawaited(CodeStore.instance.addCode(code));
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -582,7 +568,7 @@ Widget clippedCard(AppLocalizations l10n) {
|
||||
),
|
||||
);
|
||||
if (code != null) {
|
||||
await CodeStore.instance.updateCode(widget.code, code);
|
||||
await CodeStore.instance.addCode(code);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -629,7 +615,7 @@ Widget clippedCard(AppLocalizations l10n) {
|
||||
display: display.copyWith(pinned: !currentlyPinned),
|
||||
);
|
||||
unawaited(
|
||||
CodeStore.instance.updateCode(widget.code,code).then(
|
||||
CodeStore.instance.addCode(code).then(
|
||||
(value) => showToast(
|
||||
context,
|
||||
!currentlyPinned
|
||||
@@ -708,7 +694,7 @@ Widget clippedCard(AppLocalizations l10n) {
|
||||
final Code code = widget.code.copyWith(
|
||||
display: display.copyWith(trashed: true),
|
||||
);
|
||||
await CodeStore.instance.updateCode(widget.code, code);
|
||||
await CodeStore.instance.addCode(code);
|
||||
} catch (e) {
|
||||
logger.severe('Failed to trash code: ${e.toString()}');
|
||||
showGenericErrorDialog(context: context, error: e).ignore();
|
||||
@@ -732,7 +718,7 @@ Widget clippedCard(AppLocalizations l10n) {
|
||||
final Code code = widget.code.copyWith(
|
||||
display: display.copyWith(trashed: false),
|
||||
);
|
||||
await CodeStore.instance.updateCode(widget.code, code);
|
||||
await CodeStore.instance.addCode(code);
|
||||
} catch (e) {
|
||||
logger.severe('Failed to restore code: ${e.toString()}');
|
||||
if (mounted) {
|
||||
|
||||
@@ -1,243 +0,0 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:ente_auth/l10n/l10n.dart';
|
||||
import 'package:ente_auth/models/code.dart';
|
||||
import 'package:ente_auth/store/code_display_store.dart';
|
||||
import 'package:ente_auth/store/code_store.dart';
|
||||
import 'package:ente_auth/theme/ente_theme.dart';
|
||||
import 'package:ente_auth/ui/components/buttons/button_widget.dart';
|
||||
import 'package:ente_auth/ui/components/models/button_type.dart';
|
||||
import 'package:ente_auth/ui/utils/icon_utils.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
|
||||
|
||||
class AddTagSheet extends StatefulWidget {
|
||||
final List<Code> selectedCodes;
|
||||
|
||||
const AddTagSheet({
|
||||
super.key,
|
||||
required this.selectedCodes,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AddTagSheet> createState() => _AddTagSheetState();
|
||||
}
|
||||
|
||||
class _AddTagSheetState extends State<AddTagSheet> {
|
||||
List<String> _allTags = [];
|
||||
final Set<String> _selectedTagsInSheet = {};
|
||||
bool _isLoading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadInitialState();
|
||||
}
|
||||
|
||||
Future<void> _loadInitialState() async {
|
||||
final allTagsFromServer = await CodeDisplayStore.instance.getAllTags();
|
||||
final initialTagsForSelection = <String>{};
|
||||
|
||||
for (final code in widget.selectedCodes) {
|
||||
initialTagsForSelection.addAll(code.display.tags);
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_allTags = allTagsFromServer;
|
||||
_selectedTagsInSheet.addAll(initialTagsForSelection);
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onDonePressed() async {
|
||||
final List<Future> updateFutures = [];
|
||||
for (final code in widget.selectedCodes) {
|
||||
final updatedCode = code.copyWith(
|
||||
display: code.display.copyWith(tags: _selectedTagsInSheet.toList()),
|
||||
);
|
||||
updateFutures.add(CodeStore.instance.updateCode(code, updatedCode));
|
||||
}
|
||||
await Future.wait(updateFutures);
|
||||
if (mounted) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _showCreateTagDialog() async {
|
||||
final textController = TextEditingController();
|
||||
final newTag = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return StatefulBuilder(
|
||||
builder: (context, setState) {
|
||||
return AlertDialog(
|
||||
title: Text(context.l10n.createNewTag),
|
||||
content: TextField(
|
||||
controller: textController,
|
||||
autofocus: true,
|
||||
onChanged: (_) => setState(() {}),
|
||||
),
|
||||
actions: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ButtonWidget(
|
||||
buttonType: ButtonType.secondary,
|
||||
labelText: context.l10n.cancel,
|
||||
onTap: () async => Navigator.of(context).pop(),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: ButtonWidget(
|
||||
buttonType: ButtonType.primary,
|
||||
labelText: context.l10n.create,
|
||||
isDisabled: textController.text.trim().isEmpty,
|
||||
onTap: () async => Navigator.of(context).pop(textController.text.trim()),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (newTag != null && newTag.isNotEmpty) {
|
||||
setState(() {
|
||||
if (!_allTags.contains(newTag)) {
|
||||
_allTags.add(newTag);
|
||||
_allTags.sort();
|
||||
}
|
||||
_selectedTagsInSheet.add(newTag);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = getEnteColorScheme(context);
|
||||
final textTheme = getEnteTextTheme(context);
|
||||
final bottomPadding = MediaQuery.of(context).padding.bottom;
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, 16, 16, 16 + bottomPadding),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'${widget.selectedCodes.length} ${context.l10n.selected}',
|
||||
style: textTheme.large,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
height: 80,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: widget.selectedCodes.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(width: 16),
|
||||
itemBuilder: (context, index) {
|
||||
final code = widget.selectedCodes[index];
|
||||
final iconData =
|
||||
code.display.isCustomIcon ? code.display.iconID : code.issuer;
|
||||
|
||||
return SizedBox(
|
||||
width: 60,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
IconUtils.instance.getIcon(context, iconData.trim(), width: 40),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
code.issuer,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: textTheme.mini.copyWith(color: colorScheme.textMuted,),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(context.l10n.tags, style: textTheme.body),
|
||||
const SizedBox(height: 12),
|
||||
Flexible(
|
||||
child: SingleChildScrollView(
|
||||
child: _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: Wrap(
|
||||
spacing: 8.0,
|
||||
runSpacing: 4.0,
|
||||
children: [
|
||||
..._allTags.map((tag) {
|
||||
final isSelected = _selectedTagsInSheet.contains(tag);
|
||||
return ChoiceChip(
|
||||
label: Text(tag),
|
||||
selected: isSelected,
|
||||
onSelected: (selected) {
|
||||
setState(() {
|
||||
if (selected) {
|
||||
_selectedTagsInSheet.add(tag);
|
||||
} else {
|
||||
_selectedTagsInSheet.remove(tag);
|
||||
}
|
||||
});
|
||||
},
|
||||
selectedColor: colorScheme.primary400,
|
||||
backgroundColor: colorScheme.fillFaint,
|
||||
labelStyle: TextStyle(
|
||||
color: isSelected ? Colors.white : colorScheme.textBase,
|
||||
),
|
||||
avatar: isSelected ? const Icon(Icons.check, color: Colors.white, size: 16) : null,
|
||||
side: BorderSide.none,
|
||||
shape: const StadiumBorder(),
|
||||
);
|
||||
}),
|
||||
ActionChip(
|
||||
avatar: const Icon(Icons.add, size: 18),
|
||||
label: Text(context.l10n.addNew),
|
||||
onPressed: _showCreateTagDialog,
|
||||
side: BorderSide.none,
|
||||
shape: const StadiumBorder(),
|
||||
backgroundColor: colorScheme.fillFaint,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: colorScheme.fillBase,
|
||||
foregroundColor: colorScheme.backgroundBase,
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
onPressed: _onDonePressed,
|
||||
child: Text(context.l10n.done),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,6 @@ 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';
|
||||
@@ -27,22 +26,17 @@ 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';
|
||||
@@ -64,7 +58,6 @@ class HomePage extends BaseHomePage {
|
||||
}
|
||||
|
||||
class _HomePageState extends State<HomePage> {
|
||||
final _codeDisplayStore = CodeDisplayStore.instance;
|
||||
late final _settingsPage = SettingsPage(
|
||||
emailNotifier: UserService.instance.emailValueNotifier,
|
||||
scaffoldKey: scaffoldKey,
|
||||
@@ -101,7 +94,6 @@ class _HomePageState extends State<HomePage> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_codeSortKey = PreferenceService.instance.codeSortKey();
|
||||
_textController.addListener(_applyFilteringAndRefresh);
|
||||
_loadCodes();
|
||||
@@ -127,631 +119,6 @@ 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);
|
||||
@@ -786,7 +153,6 @@ Widget _buildActionButtons() {
|
||||
}
|
||||
|
||||
void _loadCodes() {
|
||||
debugPrint("[HOME_DEBUG] _loadCodes triggered!");
|
||||
CodeStore.instance.getAllCodes().then((codes) {
|
||||
_allCodes = codes;
|
||||
hasTrashedCodes = false;
|
||||
@@ -1053,135 +419,120 @@ Widget _buildActionButtons() {
|
||||
}
|
||||
|
||||
@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 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(
|
||||
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(
|
||||
bottom: false,
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
return _getBody();
|
||||
},
|
||||
),
|
||||
),
|
||||
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,
|
||||
),
|
||||
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,
|
||||
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();
|
||||
}
|
||||
),
|
||||
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,
|
||||
),
|
||||
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();
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
floatingActionButton: isSelecting
|
||||
? null
|
||||
: (!_hasLoaded ||
|
||||
(_allCodes?.isEmpty ?? true) ||
|
||||
!PreferenceService.instance.hasShownCoachMark()
|
||||
? null
|
||||
: _getFab()),
|
||||
),
|
||||
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();
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
floatingActionButton: !_hasLoaded ||
|
||||
(_allCodes?.isEmpty ?? true) ||
|
||||
!PreferenceService.instance.hasShownCoachMark()
|
||||
? null
|
||||
: _getFab(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _getBody() {
|
||||
final l10n = context.l10n;
|
||||
@@ -1219,7 +570,6 @@ Widget build(BuildContext context) {
|
||||
? TagChipState.selected
|
||||
: TagChipState.unselected,
|
||||
onTap: () {
|
||||
_codeDisplayStore.clearSelection();
|
||||
selectedTag = "";
|
||||
_isTrashOpen = false;
|
||||
|
||||
@@ -1236,7 +586,6 @@ Widget build(BuildContext context) {
|
||||
? TagChipState.selected
|
||||
: TagChipState.unselected,
|
||||
onTap: () {
|
||||
_codeDisplayStore.clearSelection();
|
||||
selectedTag = "";
|
||||
_isTrashOpen = !_isTrashOpen;
|
||||
setState(() {});
|
||||
@@ -1254,7 +603,6 @@ Widget build(BuildContext context) {
|
||||
? TagChipState.selected
|
||||
: TagChipState.unselected,
|
||||
onTap: () {
|
||||
_codeDisplayStore.clearSelection();
|
||||
_isTrashOpen = false;
|
||||
if (selectedTag == tags[customTagIndex]) {
|
||||
selectedTag = "";
|
||||
@@ -1469,4 +817,4 @@ Widget build(BuildContext context) {
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ 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';
|
||||
@@ -30,10 +29,6 @@ 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 = [];
|
||||
@@ -91,21 +86,10 @@ class DataSectionWidget extends StatelessWidget {
|
||||
);
|
||||
},
|
||||
),
|
||||
MenuItemWidget(
|
||||
captionedTextWidget: CaptionedTextWidget(
|
||||
title: l10n.localBackupSidebarTitle,
|
||||
),
|
||||
pressedColor: getEnteColorScheme(context).fillFaint,
|
||||
trailingIcon: Icons.chevron_right_outlined,
|
||||
trailingIconIsMuted: true,
|
||||
onTap: () async {
|
||||
await _handleLocalBackupClick(context);
|
||||
},
|
||||
),
|
||||
sectionOptionSpacing,
|
||||
]);
|
||||
return Column(
|
||||
children: children,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ 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';
|
||||
@@ -45,7 +46,7 @@ Future<void> showEncryptedImportInstruction(BuildContext context) async {
|
||||
if (result?.action != null && result!.action != ButtonAction.cancel) {
|
||||
if (result.action == ButtonAction.first) {
|
||||
await _pickEnteJsonFile(context);
|
||||
}
|
||||
} else {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,9 +58,6 @@ Future<void> _decryptExportData(
|
||||
final l10n = context.l10n;
|
||||
bool isPasswordIncorrect = false;
|
||||
int? importedCodeCount;
|
||||
|
||||
bool importHasRun = false;
|
||||
|
||||
await showTextInputDialog(
|
||||
context,
|
||||
title: l10n.passwordForDecryptingExport,
|
||||
@@ -69,11 +67,6 @@ 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), () {
|
||||
@@ -85,7 +78,6 @@ 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),
|
||||
@@ -93,6 +85,7 @@ Future<void> _decryptExportData(
|
||||
enteAuthExport.kdfParams.opsLimit,
|
||||
);
|
||||
Uint8List? decryptedContent;
|
||||
// Encrypt the key with this derived key
|
||||
try {
|
||||
decryptedContent = await CryptoUtil.decryptData(
|
||||
CryptoUtil.base642bin(enteAuthExport.encryptedData),
|
||||
@@ -106,62 +99,27 @@ 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 List<Code> parsedCodes = [];
|
||||
for (final line in splitCodes) {
|
||||
if (line.trim().isEmpty) continue;
|
||||
final parsedCodes = [];
|
||||
for (final code in splitCodes) {
|
||||
try {
|
||||
String otpUrl = jsonDecode(line);
|
||||
parsedCodes.add(Code.fromOTPAuthUrl(otpUrl));
|
||||
parsedCodes.add(Code.fromOTPAuthUrl(code));
|
||||
} catch (e) {
|
||||
Logger('EncryptedText').severe("Could not parse code", e);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
for (final code in parsedCodes) {
|
||||
await CodeStore.instance.addCode(code, shouldSync: false);
|
||||
}
|
||||
|
||||
|
||||
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;
|
||||
|
||||
unawaited(AuthenticatorService.instance.onlineSync());
|
||||
importedCodeCount = parsedCodes.length;
|
||||
await progressDialog.hide();
|
||||
} catch (e, s) {
|
||||
await progressDialog.hide();
|
||||
@@ -195,4 +153,4 @@ Future<void> _pickEnteJsonFile(BuildContext context) async {
|
||||
context.l10n.importFailureDescNew,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,458 +0,0 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:ente_auth/ente_theme_data.dart';
|
||||
import 'package:ente_auth/l10n/l10n.dart';
|
||||
import 'package:ente_auth/services/local_backup_service.dart';
|
||||
import 'package:ente_auth/theme/ente_theme.dart';
|
||||
import 'package:ente_auth/ui/components/buttons/button_widget.dart';
|
||||
import 'package:ente_auth/ui/components/dialog_widget.dart';
|
||||
import 'package:ente_auth/ui/components/models/button_result.dart';
|
||||
import 'package:ente_auth/ui/components/models/button_type.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class LocalBackupSettingsPage extends StatefulWidget {
|
||||
const LocalBackupSettingsPage({super.key});
|
||||
|
||||
@override
|
||||
State<LocalBackupSettingsPage> createState() =>
|
||||
_LocalBackupSettingsPageState();
|
||||
}
|
||||
|
||||
class _LocalBackupSettingsPageState extends State<LocalBackupSettingsPage> {
|
||||
bool _isBackupEnabled = false;
|
||||
String? _backupPath;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadSettings();
|
||||
}
|
||||
|
||||
// to load the saved settings from SharedPreferences when the page opens.
|
||||
Future<void> _loadSettings() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
setState(() {
|
||||
_isBackupEnabled = prefs.getBool('isAutoBackupEnabled') ?? false;
|
||||
_backupPath = prefs.getString('autoBackupPath');
|
||||
});
|
||||
}
|
||||
|
||||
Future<String?> _showCustomPasswordDialog() async {
|
||||
final l10n = context.l10n;
|
||||
final textController = TextEditingController();
|
||||
// state variable to track password visibility
|
||||
bool isPasswordHidden = true;
|
||||
|
||||
return showDialog<String>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) {
|
||||
return StatefulBuilder(
|
||||
builder: (context, setState) {
|
||||
return AlertDialog(
|
||||
title: Text(l10n.setPasswordTitle, style: getEnteTextTheme(context).largeBold),
|
||||
content: TextField(
|
||||
controller: textController,
|
||||
autofocus: true,
|
||||
obscureText: isPasswordHidden,
|
||||
decoration: InputDecoration(
|
||||
hintText: l10n.enterPassword,
|
||||
hintStyle: getEnteTextTheme(context).mini,
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
isPasswordHidden ? Icons.visibility_off : Icons.visibility,
|
||||
),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
isPasswordHidden = !isPasswordHidden;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
onChanged: (text) => setState(() {}),
|
||||
),
|
||||
actions: [
|
||||
Row(
|
||||
children: [
|
||||
|
||||
Expanded(
|
||||
child: ButtonWidget(
|
||||
buttonType: ButtonType.secondary,
|
||||
labelText: l10n.cancel,
|
||||
onTap: () async => Navigator.of(context).pop(null),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 8),
|
||||
|
||||
Expanded(
|
||||
child: ButtonWidget(
|
||||
buttonType: ButtonType.primary,
|
||||
labelText: l10n.saveAction,
|
||||
isDisabled: textController.text.isEmpty,
|
||||
onTap: () async => Navigator.of(context).pop(textController.text),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<ButtonResult?> _showLocationChoiceDialog({required String displayPath}) async {
|
||||
final l10n = context.l10n;
|
||||
|
||||
final dialogBody =
|
||||
'${l10n.backupLocationChoiceDescription}\n\nSelected: ${_simplifyPath(displayPath)}';
|
||||
|
||||
final result = await showDialogWidget(
|
||||
title: l10n.chooseBackupLocation,
|
||||
context: context,
|
||||
body: dialogBody,
|
||||
buttons: [
|
||||
ButtonWidget(
|
||||
buttonType: ButtonType.primary,
|
||||
labelText: l10n.saveBackup,
|
||||
isInAlert: true,
|
||||
buttonSize: ButtonSize.large,
|
||||
buttonAction: ButtonAction.first,
|
||||
),
|
||||
ButtonWidget(
|
||||
buttonType: ButtonType.secondary,
|
||||
labelText: l10n.changeLocation,
|
||||
isInAlert: true,
|
||||
buttonSize: ButtonSize.large,
|
||||
buttonAction: ButtonAction.second,
|
||||
),
|
||||
ButtonWidget(
|
||||
buttonType: ButtonType.secondary,
|
||||
labelText: l10n.cancel,
|
||||
isInAlert: true,
|
||||
buttonSize: ButtonSize.large,
|
||||
buttonAction: ButtonAction.cancel,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<bool> _handleLocationSetup() async {
|
||||
|
||||
String currentPath = _backupPath ?? await _getDefaultBackupPath();
|
||||
|
||||
while (true) {
|
||||
final result = await _showLocationChoiceDialog(displayPath: currentPath);
|
||||
|
||||
if (result?.action == ButtonAction.first) {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
try {
|
||||
await Directory(currentPath).create(recursive: true);
|
||||
await prefs.setString('autoBackupPath', currentPath);
|
||||
setState(() {
|
||||
_backupPath = currentPath;
|
||||
});
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.initialBackupCreated)),
|
||||
);
|
||||
return true;
|
||||
} catch (e) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.noDefaultBackupFolder)),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
else if (result?.action == ButtonAction.second) {
|
||||
final newPath = await FilePicker.platform.getDirectoryPath();
|
||||
if (newPath != null) {
|
||||
currentPath = newPath;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> _getDefaultBackupPath() async {
|
||||
if (Platform.isAndroid) {
|
||||
Directory? externalDir = await getExternalStorageDirectory();
|
||||
if (externalDir != null) {
|
||||
String storagePath = externalDir.path.split('/Android')[0];
|
||||
return '$storagePath/Download/EnteAuthBackups';
|
||||
}
|
||||
}
|
||||
|
||||
Directory? dir = await getDownloadsDirectory();
|
||||
dir ??= await getApplicationDocumentsDirectory();
|
||||
return '${dir.path}/EnteAuthBackups';
|
||||
}
|
||||
|
||||
String _simplifyPath(String fullPath) { //takes a file path string and shortens it if it matches the common Android root path.
|
||||
const rootToRemove = '/storage/emulated/0/';
|
||||
if (fullPath.startsWith(rootToRemove)) {
|
||||
return fullPath.substring(rootToRemove.length);
|
||||
}
|
||||
return fullPath;
|
||||
}
|
||||
|
||||
// opens directory picker
|
||||
Future<bool> _pickAndSaveBackupLocation({String? successMessage}) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final l10n = context.l10n;
|
||||
|
||||
String? directoryPath = await FilePicker.platform.getDirectoryPath();
|
||||
|
||||
if (directoryPath != null) {
|
||||
|
||||
await prefs.setString('autoBackupPath', directoryPath);
|
||||
|
||||
// we only set the state and create the backup if a path was chosen
|
||||
setState(() {
|
||||
_backupPath = directoryPath;
|
||||
});
|
||||
await LocalBackupService.instance.triggerAutomaticBackup();
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(successMessage ?? l10n.locationUpdatedAndBackupCreated),
|
||||
),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
return false; //user cancelled the file picker
|
||||
}
|
||||
|
||||
Future<void> _showSetPasswordDialog() async {
|
||||
final String? password = await _showCustomPasswordDialog();
|
||||
if (password == null) {
|
||||
setState(() {
|
||||
_isBackupEnabled = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.passwordTooShort),
|
||||
),
|
||||
);
|
||||
setState(() {
|
||||
_isBackupEnabled = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const storage = FlutterSecureStorage();
|
||||
await storage.write(key: 'autoBackupPassword', value: password);
|
||||
|
||||
SchedulerBinding.instance.addPostFrameCallback((_) async {
|
||||
final bool setupCompleted = await _handleLocationSetup();
|
||||
if (!mounted) return;
|
||||
|
||||
if (setupCompleted) {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool('isAutoBackupEnabled', true);
|
||||
setState(() {
|
||||
_isBackupEnabled = true;
|
||||
});
|
||||
await LocalBackupService.instance.triggerAutomaticBackup();
|
||||
} else {
|
||||
setState(() {
|
||||
_isBackupEnabled = false;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(l10n.localBackupSettingsTitle), //text shown on appbar
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
l10n.enableAutomaticBackups, //toggle text
|
||||
style: getEnteTextTheme(context).largeBold,
|
||||
),
|
||||
),
|
||||
Switch.adaptive(
|
||||
value: _isBackupEnabled,
|
||||
activeColor: Theme.of(context)
|
||||
.colorScheme
|
||||
.enteTheme
|
||||
.colorScheme
|
||||
.primary400,
|
||||
activeTrackColor: Theme.of(context)
|
||||
.colorScheme
|
||||
.enteTheme
|
||||
.colorScheme
|
||||
.primary300,
|
||||
inactiveTrackColor: Theme.of(context)
|
||||
.colorScheme
|
||||
.enteTheme
|
||||
.colorScheme
|
||||
.fillMuted,
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
onChanged: (value) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
if (value == true) {
|
||||
//when toggle is ON, show password dialog
|
||||
await _showSetPasswordDialog();
|
||||
} else {
|
||||
await prefs.setBool('isAutoBackupEnabled', false);
|
||||
setState(() {
|
||||
_isBackupEnabled = false;
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 10.0),
|
||||
child: Text(
|
||||
l10n.backupDescription, //text below toggle
|
||||
style: getEnteTextTheme(context).mini,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Opacity(
|
||||
opacity: _isBackupEnabled ? 1.0 : 0.4,
|
||||
child: IgnorePointer(
|
||||
ignoring: !_isBackupEnabled,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.currentLocation, //shows current backup location
|
||||
style: getEnteTextTheme(context).body,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
if (_backupPath != null)
|
||||
Text(
|
||||
_simplifyPath(_backupPath!),
|
||||
style: getEnteTextTheme(context).small,
|
||||
)
|
||||
else
|
||||
FutureBuilder<String>(
|
||||
future: _getDefaultBackupPath(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState ==
|
||||
ConnectionState.waiting) {
|
||||
return Text(
|
||||
l10n.loadDefaultLocation,
|
||||
style: getEnteTextTheme(context)
|
||||
.small
|
||||
.copyWith(color: Colors.grey),
|
||||
);
|
||||
} else if (snapshot.hasError) {
|
||||
return Text(
|
||||
l10n.couldNotDetermineLocation,
|
||||
style: getEnteTextTheme(context)
|
||||
.small
|
||||
.copyWith(color: Colors.red),
|
||||
);
|
||||
} else {
|
||||
return Text(
|
||||
_simplifyPath(snapshot.data ?? ''),
|
||||
style: getEnteTextTheme(context)
|
||||
.small
|
||||
.copyWith(color: Colors.grey),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 30),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: () => _pickAndSaveBackupLocation(),
|
||||
child: Text(l10n.changeCurrentLocation),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange.withAlpha(26),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: Colors.orange.withAlpha(77),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.security_outlined,
|
||||
color: Colors.orange,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
l10n.securityNotice, //security notice title
|
||||
style: getEnteTextTheme(context)
|
||||
.smallBold
|
||||
.copyWith(
|
||||
color: Colors.orange,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
l10n.backupSecurityNotice, //security notice description
|
||||
style: getEnteTextTheme(context).mini.copyWith(
|
||||
color: Colors.orange.shade700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -414,7 +414,6 @@ Future<dynamic> showTextInputDialog(
|
||||
bool alwaysShowSuccessState = false,
|
||||
bool isPasswordInput = false,
|
||||
bool useRootNavigator = false,
|
||||
VoidCallback? onCancel,
|
||||
}) {
|
||||
return showDialog(
|
||||
barrierColor: backdropFaintDark,
|
||||
|
||||
@@ -1153,9 +1153,9 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "."
|
||||
ref: v2-only
|
||||
resolved-ref: "0cdfeed654d79636eff0c57110f3f6ad5801ba2f"
|
||||
url: "https://github.com/ente-io/move_to_background.git"
|
||||
ref: "91e4d1a9c55b28bf93425d1f12faf410efc1e48d"
|
||||
resolved-ref: "91e4d1a9c55b28bf93425d1f12faf410efc1e48d"
|
||||
url: "https://github.com/Sayegh7/move_to_background"
|
||||
source: git
|
||||
version: "1.0.2"
|
||||
native_dio_adapter:
|
||||
|
||||
@@ -95,10 +95,10 @@ dependencies:
|
||||
local_auth_darwin: ^1.2.2
|
||||
logging: ^1.0.1
|
||||
modal_bottom_sheet: ^3.0.0
|
||||
move_to_background: # no updates in git, replace package
|
||||
move_to_background: # no package updates on pub.dev
|
||||
git:
|
||||
url: https://github.com/ente-io/move_to_background.git
|
||||
ref: v2-only
|
||||
url: https://github.com/Sayegh7/move_to_background
|
||||
ref: 91e4d1a9c55b28bf93425d1f12faf410efc1e48d
|
||||
native_dio_adapter: ^1.4.0
|
||||
otp: ^3.1.1
|
||||
package_info_plus: ^8.0.2
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
CLAUDE.md
|
||||
@@ -1,6 +1,6 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude, Codex, and any other agent when working with code in this repository.
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Philosophy
|
||||
|
||||
@@ -196,11 +196,6 @@ 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
|
||||
@@ -210,4 +205,4 @@ lib/
|
||||
- Always follow existing code conventions and patterns in neighboring files
|
||||
|
||||
# Individual Preferences
|
||||
- @~/.claude/ente-photos-instructions.md
|
||||
- @~/.claude/my-project-instructions.md
|
||||
|
||||
3530
mobile/apps/photos/assets/ml/text_embeddings.json
Normal file
3530
mobile/apps/photos/assets/ml/text_embeddings.json
Normal file
File diff suppressed because it is too large
Load Diff
45
mobile/apps/photos/ios/ci_scripts/ci_post_clone.sh
Executable file
45
mobile/apps/photos/ios/ci_scripts/ci_post_clone.sh
Executable file
@@ -0,0 +1,45 @@
|
||||
#!/bin/sh
|
||||
|
||||
# ci_post_clone.sh
|
||||
# This script runs after the repository is cloned
|
||||
|
||||
set -e
|
||||
|
||||
echo "🚀 Starting post-clone setup..."
|
||||
|
||||
# Navigate to the Flutter project root
|
||||
cd ../../ # Adjust path based on your structure
|
||||
|
||||
# Install Flutter
|
||||
echo "📦 Installing Flutter..."
|
||||
FLUTTER_VERSION="3.32.8"
|
||||
git clone https://github.com/flutter/flutter.git --branch $FLUTTER_VERSION --depth 1 $HOME/flutter
|
||||
export PATH="$PATH:$HOME/flutter/bin"
|
||||
|
||||
# Verify Flutter installation
|
||||
flutter --version
|
||||
flutter doctor -v
|
||||
|
||||
# Install Rust (required for Flutter Rust Bridge)
|
||||
echo "🦀 Installing Rust..."
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
|
||||
source $HOME/.cargo/env
|
||||
export PATH="$HOME/.cargo/bin:$PATH"
|
||||
|
||||
# Install Flutter Rust Bridge
|
||||
echo "🌉 Installing Flutter Rust Bridge..."
|
||||
cargo install flutter_rust_bridge_codegen
|
||||
|
||||
# Generate Rust bindings
|
||||
echo "⚙️ Generating Rust bindings..."
|
||||
flutter_rust_bridge_codegen generate
|
||||
|
||||
# Get Flutter dependencies
|
||||
echo "📚 Getting Flutter dependencies..."
|
||||
flutter pub get
|
||||
|
||||
# Generate iOS podfile if needed
|
||||
cd ios
|
||||
pod install --repo-update
|
||||
|
||||
echo "✅ Post-clone setup completed!"
|
||||
0
mobile/apps/photos/ios/ci_scripts/ci_post_xcodebuild.sh
Executable file
0
mobile/apps/photos/ios/ci_scripts/ci_post_xcodebuild.sh
Executable file
25
mobile/apps/photos/ios/ci_scripts/ci_pre_xcodebuild.sh
Executable file
25
mobile/apps/photos/ios/ci_scripts/ci_pre_xcodebuild.sh
Executable file
@@ -0,0 +1,25 @@
|
||||
#!/bin/sh
|
||||
|
||||
# ci_pre_xcodebuild.sh
|
||||
# This script runs before xcodebuild
|
||||
|
||||
set -e
|
||||
|
||||
echo "🔧 Pre-build setup starting..."
|
||||
|
||||
# Set up environment
|
||||
export PATH="$PATH:$HOME/flutter/bin"
|
||||
export PATH="$HOME/.cargo/bin:$PATH"
|
||||
source $HOME/.cargo/env || true
|
||||
|
||||
# Navigate to Flutter project root
|
||||
cd ../../../
|
||||
|
||||
# Build Flutter iOS framework
|
||||
echo "🏗️ Building Flutter framework..."
|
||||
flutter build ios-framework --no-debug --no-profile
|
||||
|
||||
# Run any code generation if needed
|
||||
flutter pub run build_runner build --delete-conflicting-outputs || true
|
||||
|
||||
echo "✅ Pre-build setup completed!"
|
||||
10
mobile/apps/photos/ios/xcode-cloud.json
Normal file
10
mobile/apps/photos/ios/xcode-cloud.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Release Build",
|
||||
"scheme": "Runner",
|
||||
"archive_configuration": "Release",
|
||||
"destination": "generic/platform=iOS"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -5,10 +5,9 @@ import 'dart:io';
|
||||
|
||||
import "package:dio/dio.dart";
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:log_viewer/log_viewer.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:path/path.dart';
|
||||
@@ -189,15 +188,6 @@ 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,
|
||||
@@ -465,15 +455,4 @@ class SuperLogging {
|
||||
final pkgName = (await PackageInfo.fromPlatform()).packageName;
|
||||
return pkgName.startsWith("io.ente.photos.fdroid");
|
||||
}
|
||||
|
||||
/// Show the log viewer page
|
||||
/// This is the main integration point for accessing the log viewer
|
||||
static void showLogViewer(BuildContext context) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const LogViewerPage(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,7 +62,6 @@ class MLDataDB with SqlDbBase implements IMLDataDB<int> {
|
||||
createClipEmbeddingsTable,
|
||||
createFileDataTable,
|
||||
createFaceCacheTable,
|
||||
createTextEmbeddingsCacheTable,
|
||||
];
|
||||
|
||||
// only have a single app-wide reference to the database
|
||||
@@ -1430,56 +1429,6 @@ class MLDataDB with SqlDbBase implements IMLDataDB<int> {
|
||||
Bus.instance.fire(EmbeddingUpdatedEvent());
|
||||
}
|
||||
|
||||
/// WARNING: don't confuse this with [putClip]. If you're not sure, use [putClip]
|
||||
Future<void> putRepeatedTextEmbeddingCache(
|
||||
String query,
|
||||
List<double> embedding,
|
||||
) async {
|
||||
final db = await asyncDB;
|
||||
await db.execute(
|
||||
'INSERT OR REPLACE INTO $textEmbeddingsCacheTable '
|
||||
'($textQueryColumn, $embeddingColumn, $mlVersionColumn, $createdAtColumn) '
|
||||
'VALUES (?, ?, ?, ?)',
|
||||
[
|
||||
query,
|
||||
Float32List.fromList(embedding).buffer.asUint8List(),
|
||||
clipMlVersion,
|
||||
DateTime.now().millisecondsSinceEpoch,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// WARNING: don't confuse this with [getAllClipVectors]. If you're not sure, use [getAllClipVectors]
|
||||
Future<List<double>?> getRepeatedTextEmbeddingCache(String query) async {
|
||||
final db = await asyncDB;
|
||||
final results = await db.getAll(
|
||||
'SELECT $embeddingColumn, $mlVersionColumn, $createdAtColumn '
|
||||
'FROM $textEmbeddingsCacheTable '
|
||||
'WHERE $textQueryColumn = ?',
|
||||
[query],
|
||||
);
|
||||
|
||||
if (results.isEmpty) return null;
|
||||
|
||||
final threeMonthsAgo =
|
||||
DateTime.now().millisecondsSinceEpoch - (90 * 24 * 60 * 60 * 1000);
|
||||
|
||||
// Find first valid entry
|
||||
for (final result in results) {
|
||||
if (result[mlVersionColumn] == clipMlVersion &&
|
||||
result[createdAtColumn] as int > threeMonthsAgo) {
|
||||
return Float32List.view((result[embeddingColumn] as Uint8List).buffer);
|
||||
}
|
||||
}
|
||||
|
||||
// No valid entry found, clean up
|
||||
await db.execute(
|
||||
'DELETE FROM $textEmbeddingsCacheTable WHERE $textQueryColumn = ?',
|
||||
[query],
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteClipEmbeddings(List<int> fileIDs) async {
|
||||
final db = await instance.asyncDB;
|
||||
|
||||
@@ -16,8 +16,6 @@ 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,
|
||||
@@ -139,18 +137,3 @@ CREATE TABLE IF NOT EXISTS $faceCacheTable (
|
||||
''';
|
||||
|
||||
const deleteFaceCacheTable = 'DELETE FROM $faceCacheTable';
|
||||
|
||||
// ## TEXT EMBEDDINGS CACHE TABLE
|
||||
const textEmbeddingsCacheTable = 'text_embeddings_cache';
|
||||
|
||||
const createTextEmbeddingsCacheTable = '''
|
||||
CREATE TABLE IF NOT EXISTS $textEmbeddingsCacheTable (
|
||||
$textQueryColumn TEXT NOT NULL,
|
||||
$embeddingColumn BLOB NOT NULL,
|
||||
$mlVersionColumn INTEGER NOT NULL,
|
||||
$createdAtColumn INTEGER NOT NULL,
|
||||
PRIMARY KEY ($textQueryColumn)
|
||||
);
|
||||
''';
|
||||
|
||||
const deleteTextEmbeddingsCacheTable = 'DELETE FROM $textEmbeddingsCacheTable';
|
||||
|
||||
@@ -183,7 +183,7 @@ class UploadLocksDB {
|
||||
return "No lock found for $id";
|
||||
}
|
||||
final row = rows.first;
|
||||
final time = int.tryParse(row[_uploadLocksTable.columnTime].toString()) ?? 0 ;
|
||||
final time = row[_uploadLocksTable.columnTime] as int;
|
||||
final owner = row[_uploadLocksTable.columnOwner] as String;
|
||||
final duration = DateTime.now().millisecondsSinceEpoch - time;
|
||||
return "Lock for $id acquired by $owner since ${Duration(milliseconds: duration)}";
|
||||
|
||||
@@ -19,7 +19,6 @@ 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";
|
||||
@@ -137,12 +136,6 @@ SmartMemoriesService get smartMemoriesService {
|
||||
return _smartMemoriesService!;
|
||||
}
|
||||
|
||||
TextEmbeddingsCacheService? _textEmbeddingsCacheService;
|
||||
TextEmbeddingsCacheService get textEmbeddingsCacheService {
|
||||
_textEmbeddingsCacheService ??= TextEmbeddingsCacheService.instance;
|
||||
return _textEmbeddingsCacheService!;
|
||||
}
|
||||
|
||||
BillingService? _billingService;
|
||||
BillingService get billingService {
|
||||
_billingService ??= BillingService(
|
||||
|
||||
@@ -193,22 +193,15 @@ class SemanticSearchService {
|
||||
return results;
|
||||
}
|
||||
|
||||
/// 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(
|
||||
Future<Map<String, List<int>>> getMatchingFileIDs(
|
||||
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;
|
||||
// Use cache service instead of _getTextEmbedding
|
||||
final textEmbedding =
|
||||
await textEmbeddingsCacheService.getEmbedding(query);
|
||||
final textEmbedding = await _getTextEmbedding(query);
|
||||
textEmbeddings[query] = textEmbedding;
|
||||
minimumSimilarityMap[query] = score;
|
||||
}
|
||||
@@ -217,7 +210,6 @@ class SemanticSearchService {
|
||||
textEmbeddings,
|
||||
minimumSimilarityMap: minimumSimilarityMap,
|
||||
);
|
||||
|
||||
final result = <String, List<int>>{};
|
||||
for (final entry in queryResults.entries) {
|
||||
final query = entry.key;
|
||||
|
||||
@@ -401,8 +401,8 @@ class MagicCacheService {
|
||||
for (Prompt prompt in magicPromptsData) {
|
||||
queryToScore[prompt.query] = prompt.minScore;
|
||||
}
|
||||
final clipResults = await SemanticSearchService.instance
|
||||
.getMatchingFileIDsForCommonQueries(queryToScore);
|
||||
final clipResults =
|
||||
await SemanticSearchService.instance.getMatchingFileIDs(queryToScore);
|
||||
for (Prompt prompt in magicPromptsData) {
|
||||
final List<int> fileUploadedIDs = clipResults[prompt.query] ?? [];
|
||||
if (fileUploadedIDs.isNotEmpty) {
|
||||
|
||||
@@ -37,6 +37,7 @@ 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;
|
||||
@@ -102,29 +103,18 @@ class SmartMemoriesService {
|
||||
'allImageEmbeddings has ${allImageEmbeddings.length} entries $t',
|
||||
);
|
||||
|
||||
_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),
|
||||
// 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',
|
||||
);
|
||||
}
|
||||
|
||||
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');
|
||||
_logger.info('Using pre-computed text embeddings from assets');
|
||||
final clipPositiveTextVector = textEmbeddings.clipPositiveVector;
|
||||
final clipPeopleActivityVectors = textEmbeddings.peopleActivityVectors;
|
||||
final clipMemoryTypeVectors = textEmbeddings.clipMemoryTypeVectors;
|
||||
|
||||
final local = await getLocale();
|
||||
final languageCode = local?.languageCode ?? "en";
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,6 @@ class DebugSectionWidget extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _DebugSectionWidgetState extends State<DebugSectionWidget> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ExpandableMenuItemWidget(
|
||||
@@ -36,27 +35,6 @@ class _DebugSectionWidgetState extends State<DebugSectionWidget> {
|
||||
Widget _getSectionOptions(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
sectionOptionSpacing,
|
||||
MenuItemWidget(
|
||||
captionedTextWidget: const CaptionedTextWidget(
|
||||
title: "Enable database logging",
|
||||
),
|
||||
pressedColor: getEnteColorScheme(context).fillFaint,
|
||||
trailingWidget: ToggleSwitchWidget(
|
||||
value: () => localSettings.enableDatabaseLogging,
|
||||
onChanged: () async {
|
||||
final newValue = !localSettings.enableDatabaseLogging;
|
||||
await localSettings.setEnableDatabaseLogging(newValue);
|
||||
setState(() {});
|
||||
showShortToast(
|
||||
context,
|
||||
newValue
|
||||
? "Database logging enabled. Restart app."
|
||||
: "Database logging disabled. Restart app.",
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
sectionOptionSpacing,
|
||||
MenuItemWidget(
|
||||
captionedTextWidget: const CaptionedTextWidget(
|
||||
|
||||
@@ -3,7 +3,6 @@ import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import "package:flutter_animate/flutter_animate.dart";
|
||||
import "package:log_viewer/log_viewer.dart";
|
||||
import 'package:photos/core/configuration.dart';
|
||||
import 'package:photos/core/event_bus.dart';
|
||||
import 'package:photos/events/opened_settings_event.dart';
|
||||
@@ -36,7 +35,6 @@ class SettingsPage extends StatelessWidget {
|
||||
|
||||
const SettingsPage({super.key, required this.emailNotifier});
|
||||
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Bus.instance.fire(OpenedSettingsEvent());
|
||||
@@ -72,36 +70,12 @@ class SettingsPage extends StatelessWidget {
|
||||
// [AnimatedBuilder] accepts any [Listenable] subtype.
|
||||
animation: emailNotifier,
|
||||
builder: (BuildContext context, Widget? child) {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
emailNotifier.value!,
|
||||
style: enteTextTheme.body.copyWith(
|
||||
color: colorScheme.textMuted,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (localSettings.enableDatabaseLogging)
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const LogViewerPage(),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Icon(
|
||||
Icons.bug_report,
|
||||
size: 20,
|
||||
color: colorScheme.textMuted,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
return Text(
|
||||
emailNotifier.value!,
|
||||
style: enteTextTheme.body.copyWith(
|
||||
color: colorScheme.textMuted,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -7,7 +7,6 @@ import "package:flutter/services.dart";
|
||||
import "package:photos/generated/l10n.dart";
|
||||
import "package:photos/models/api/collection/public_url.dart";
|
||||
import 'package:photos/models/collection/collection.dart';
|
||||
import 'package:photos/service_locator.dart';
|
||||
import 'package:photos/services/collections_service.dart';
|
||||
import 'package:photos/theme/colors.dart';
|
||||
import 'package:photos/theme/ente_theme.dart';
|
||||
@@ -20,7 +19,6 @@ import "package:photos/ui/components/toggle_switch_widget.dart";
|
||||
import 'package:photos/ui/notification/toast.dart';
|
||||
import 'package:photos/ui/sharing/pickers/device_limit_picker_page.dart';
|
||||
import 'package:photos/ui/sharing/pickers/link_expiry_picker_page.dart';
|
||||
import 'package:photos/ui/sharing/qr_code_dialog_widget.dart';
|
||||
import 'package:photos/utils/dialog_util.dart';
|
||||
import 'package:photos/utils/navigation_util.dart';
|
||||
import "package:photos/utils/share_util.dart";
|
||||
@@ -53,8 +51,6 @@ 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 =
|
||||
@@ -96,31 +92,6 @@ 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(
|
||||
@@ -320,32 +291,6 @@ class _ManageSharedLinkWidgetState extends State<ManageSharedLinkWidget> {
|
||||
);
|
||||
},
|
||||
isTopBorderRadiusRemoved: true,
|
||||
isBottomBorderRadiusRemoved: flagService.internalUser,
|
||||
),
|
||||
if (!url.isExpired && flagService.internalUser)
|
||||
DividerWidget(
|
||||
dividerType: DividerType.menu,
|
||||
bgColor: getEnteColorScheme(context).fillFaint,
|
||||
),
|
||||
if (!url.isExpired && flagService.internalUser)
|
||||
MenuItemWidget(
|
||||
captionedTextWidget: const CaptionedTextWidget(
|
||||
title: "Send QR Code (i)",
|
||||
makeTextBold: true,
|
||||
),
|
||||
leadingIcon: Icons.qr_code_outlined,
|
||||
menuItemColor: getEnteColorScheme(context).fillFaint,
|
||||
onTap: () async {
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return QrCodeDialogWidget(
|
||||
collection: widget.collection!,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
isTopBorderRadiusRemoved: true,
|
||||
),
|
||||
const SizedBox(
|
||||
height: 24,
|
||||
|
||||
@@ -1,211 +0,0 @@
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:photos/models/collection/collection.dart';
|
||||
import 'package:photos/services/collections_service.dart';
|
||||
import 'package:photos/theme/ente_theme.dart';
|
||||
import 'package:photos/ui/components/buttons/button_widget.dart';
|
||||
import 'package:photos/ui/components/models/button_type.dart';
|
||||
import 'package:qr_flutter/qr_flutter.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
|
||||
class QrCodeDialogWidget extends StatefulWidget {
|
||||
final Collection collection;
|
||||
|
||||
const QrCodeDialogWidget({
|
||||
super.key,
|
||||
required this.collection,
|
||||
});
|
||||
|
||||
@override
|
||||
State<QrCodeDialogWidget> createState() => _QrCodeDialogWidgetState();
|
||||
}
|
||||
|
||||
class _QrCodeDialogWidgetState extends State<QrCodeDialogWidget> {
|
||||
final GlobalKey _qrKey = GlobalKey();
|
||||
|
||||
Future<void> _shareQrCode() async {
|
||||
try {
|
||||
final RenderRepaintBoundary boundary =
|
||||
_qrKey.currentContext!.findRenderObject() as RenderRepaintBoundary;
|
||||
final ui.Image image = await boundary.toImage(pixelRatio: 3.0);
|
||||
final ByteData? byteData =
|
||||
await image.toByteData(format: ui.ImageByteFormat.png);
|
||||
if (byteData != null) {
|
||||
final Uint8List pngBytes = byteData.buffer.asUint8List();
|
||||
|
||||
final directory = await getTemporaryDirectory();
|
||||
final file = File(
|
||||
'${directory.path}/ente_qr_${widget.collection.displayName}.png',
|
||||
);
|
||||
await file.writeAsBytes(pngBytes);
|
||||
|
||||
await Share.shareXFiles(
|
||||
[XFile(file.path)],
|
||||
text:
|
||||
'Scan this QR code to view my ${widget.collection.displayName} album on ente',
|
||||
);
|
||||
|
||||
// Close the dialog after sharing is initiated
|
||||
if (mounted) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Error sharing QR code: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
final double qrSize = min(screenWidth - 80, 300.0);
|
||||
final enteTextTheme = getEnteTextTheme(context);
|
||||
final enteColorScheme = getEnteColorScheme(context);
|
||||
|
||||
// Get the public URL for the collection
|
||||
final String publicUrl =
|
||||
CollectionsService.instance.getPublicUrl(widget.collection);
|
||||
|
||||
// Get album name, truncate if too long
|
||||
final String albumName = widget.collection.displayName.length > 30
|
||||
? '${widget.collection.displayName.substring(0, 27)}...'
|
||||
: widget.collection.displayName;
|
||||
|
||||
return Dialog(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: enteColorScheme.backgroundBase,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Header with close button
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
"QR Code",
|
||||
style: enteTextTheme.largeBold,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
color: enteColorScheme.strokeBase,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// QR Code with RepaintBoundary for sharing
|
||||
RepaintBoundary(
|
||||
key: _qrKey,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(28),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: Colors.grey.shade200,
|
||||
width: 1,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.04),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Album name at top center (inside border) - Reduced size
|
||||
Text(
|
||||
albumName,
|
||||
style: enteTextTheme.bodyBold.copyWith(
|
||||
color: Colors.black87,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.3,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// QR Code with better spacing
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: Colors.grey.shade100,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: QrImageView(
|
||||
data: publicUrl,
|
||||
version: QrVersions.auto,
|
||||
size: qrSize - 100,
|
||||
eyeStyle: const QrEyeStyle(
|
||||
eyeShape: QrEyeShape.square,
|
||||
color: Colors.black,
|
||||
),
|
||||
dataModuleStyle: const QrDataModuleStyle(
|
||||
dataModuleShape: QrDataModuleShape.square,
|
||||
color: Colors.black,
|
||||
),
|
||||
errorCorrectionLevel: QrErrorCorrectLevel.M,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
),
|
||||
|
||||
// Ente branding at bottom right (inside border) - Fixed positioning
|
||||
Positioned(
|
||||
bottom: -2,
|
||||
right: 2,
|
||||
child: Text(
|
||||
'ente',
|
||||
style: enteTextTheme.small.copyWith(
|
||||
color: enteColorScheme.primary700,
|
||||
fontSize: 14,
|
||||
letterSpacing: 1.2,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Share button
|
||||
ButtonWidget(
|
||||
buttonType: ButtonType.primary,
|
||||
icon: Icons.adaptive.share,
|
||||
labelText: "Share",
|
||||
onTap: _shareQrCode,
|
||||
shouldSurfaceExecutionStates: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,10 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import "package:photos/extensions/user_extension.dart";
|
||||
import "package:photos/generated/l10n.dart";
|
||||
import "package:photos/models/api/collection/user.dart";
|
||||
import 'package:photos/models/collection/collection.dart';
|
||||
import 'package:photos/service_locator.dart';
|
||||
import 'package:photos/services/collections_service.dart';
|
||||
import 'package:photos/theme/ente_theme.dart';
|
||||
import 'package:photos/ui/actions/collection/collection_sharing_actions.dart';
|
||||
@@ -21,7 +19,6 @@ import 'package:photos/ui/sharing/album_participants_page.dart';
|
||||
import "package:photos/ui/sharing/album_share_info_widget.dart";
|
||||
import "package:photos/ui/sharing/manage_album_participant.dart";
|
||||
import 'package:photos/ui/sharing/manage_links_widget.dart';
|
||||
import 'package:photos/ui/sharing/qr_code_dialog_widget.dart';
|
||||
import 'package:photos/ui/sharing/user_avator_widget.dart';
|
||||
import 'package:photos/utils/navigation_util.dart';
|
||||
import 'package:photos/utils/share_util.dart';
|
||||
@@ -217,34 +214,8 @@ class _ShareCollectionPageState extends State<ShareCollectionPage> {
|
||||
);
|
||||
},
|
||||
isTopBorderRadiusRemoved: true,
|
||||
isBottomBorderRadiusRemoved: flagService.internalUser,
|
||||
isBottomBorderRadiusRemoved: true,
|
||||
),
|
||||
if (flagService.internalUser)
|
||||
DividerWidget(
|
||||
dividerType: DividerType.menu,
|
||||
bgColor: getEnteColorScheme(context).fillFaint,
|
||||
),
|
||||
if (flagService.internalUser)
|
||||
MenuItemWidget(
|
||||
captionedTextWidget: const CaptionedTextWidget(
|
||||
title: "Send QR Code (i)",
|
||||
makeTextBold: true,
|
||||
),
|
||||
leadingIcon: Icons.qr_code_outlined,
|
||||
menuItemColor: getEnteColorScheme(context).fillFaint,
|
||||
onTap: () async {
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return QrCodeDialogWidget(
|
||||
collection: widget.collection,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
isTopBorderRadiusRemoved: true,
|
||||
isBottomBorderRadiusRemoved: true,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'package:photos/core/constants.dart';
|
||||
import 'package:photos/ui/viewer/gallery/component/group/type.dart';
|
||||
import "package:photos/utils/ram_check_util.dart";
|
||||
@@ -44,7 +42,6 @@ class LocalSettings {
|
||||
static const kCollectionViewType = "collection_view_type";
|
||||
static const kCollectionSortDirection = "collection_sort_direction";
|
||||
static const kShowLocalIDOverThumbnails = "show_local_id_over_thumbnails";
|
||||
static const kEnableDatabaseLogging = "enable_db_logging";
|
||||
|
||||
// Thumbnail queue configuration keys
|
||||
static const kSmallQueueMaxConcurrent = "small_queue_max_concurrent";
|
||||
@@ -237,13 +234,6 @@ class LocalSettings {
|
||||
await _prefs.setBool(kShowLocalIDOverThumbnails, value);
|
||||
}
|
||||
|
||||
bool get enableDatabaseLogging =>
|
||||
_prefs.getBool(kEnableDatabaseLogging) ?? kDebugMode;
|
||||
|
||||
Future<void> setEnableDatabaseLogging(bool value) async {
|
||||
await _prefs.setBool(kEnableDatabaseLogging, value);
|
||||
}
|
||||
|
||||
// Thumbnail queue configuration - Small queue
|
||||
int get smallQueueMaxConcurrent =>
|
||||
_prefs.getInt(kSmallQueueMaxConcurrent) ?? 15;
|
||||
|
||||
172
mobile/apps/photos/lib/utils/text_embeddings_util.dart
Normal file
172
mobile/apps/photos/lib/utils/text_embeddings_util.dart
Normal file
@@ -0,0 +1,172 @@
|
||||
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',
|
||||
);
|
||||
}
|
||||
@@ -1512,13 +1512,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.11"
|
||||
log_viewer:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "../../packages/log_viewer"
|
||||
relative: true
|
||||
source: path
|
||||
version: "1.0.0"
|
||||
logger:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -2147,22 +2140,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.5.0"
|
||||
qr:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: qr
|
||||
sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.2"
|
||||
qr_flutter:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: qr_flutter
|
||||
sha256: "5095f0fc6e3f71d08adef8feccc8cea4f12eec18a2e31c2e8d82cb6019f4b097"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.0"
|
||||
quiver:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -12,7 +12,7 @@ description: ente photos application
|
||||
# Read more about iOS versioning at
|
||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
|
||||
version: 1.2.6+1205
|
||||
version: 1.2.5+1205
|
||||
publish_to: none
|
||||
|
||||
environment:
|
||||
@@ -123,8 +123,6 @@ dependencies:
|
||||
local_auth: ^2.1.5
|
||||
local_auth_android:
|
||||
local_auth_ios:
|
||||
log_viewer:
|
||||
path: ../../packages/log_viewer
|
||||
logging: ^1.3.0
|
||||
lottie: ^3.3.1
|
||||
maps_launcher: ^3.0.0+1
|
||||
@@ -177,7 +175,6 @@ dependencies:
|
||||
url: https://github.com/ente-io/privacy_screen.git
|
||||
ref: v2-only
|
||||
pro_image_editor: ^6.0.0
|
||||
qr_flutter: ^4.1.0
|
||||
receive_sharing_intent: # pub.dev is behind
|
||||
git:
|
||||
url: https://github.com/KasemJaffer/receive_sharing_intent.git
|
||||
@@ -349,6 +346,7 @@ flutter:
|
||||
- assets/image-editor/
|
||||
- assets/icons/
|
||||
- assets/launcher_icon/
|
||||
- assets/ml/
|
||||
fonts:
|
||||
- family: Inter
|
||||
fonts:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# melos_managed_dependency_overrides: ente_cast,ente_cast_normal,ente_crypto,ente_feature_flag,onnx_dart,ffi,flutter_sodium,intl,js,media_kit,media_kit_libs_ios_video,media_kit_libs_video,media_kit_video,protobuf,video_player,watcher,win32,log_viewer
|
||||
# melos_managed_dependency_overrides: ente_cast,ente_cast_normal,ente_crypto,ente_feature_flag,onnx_dart,ffi,flutter_sodium,intl,js,media_kit,media_kit_libs_ios_video,media_kit_libs_video,media_kit_video,protobuf,video_player,watcher,win32
|
||||
dependency_overrides:
|
||||
ente_cast:
|
||||
path: plugins/ente_cast
|
||||
@@ -41,5 +41,3 @@ dependency_overrides:
|
||||
path: packages/video_player/video_player/
|
||||
watcher: ^1.1.0
|
||||
win32: 5.10.1
|
||||
log_viewer:
|
||||
path: ../../packages/log_viewer
|
||||
|
||||
@@ -10,7 +10,7 @@ Pod::Spec.new do |s|
|
||||
A new Flutter FFI plugin project.
|
||||
DESC
|
||||
s.homepage = 'http://example.com'
|
||||
s.license = { :file => '../LICENSE' }
|
||||
s.license = { :type => 'AGPL-3.0' }
|
||||
s.author = { 'Your Company' => 'email@example.com' }
|
||||
|
||||
# This will ensure the source files in Classes/ are included in the native
|
||||
|
||||
@@ -10,7 +10,7 @@ Pod::Spec.new do |s|
|
||||
A new Flutter FFI plugin project.
|
||||
DESC
|
||||
s.homepage = 'http://example.com'
|
||||
s.license = { :file => '../LICENSE' }
|
||||
s.license = { :type => 'AGPL-3.0' }
|
||||
s.author = { 'Your Company' => 'email@example.com' }
|
||||
|
||||
# This will ensure the source files in Classes/ are included in the native
|
||||
|
||||
@@ -1,6 +1 @@
|
||||
- Neeraj: (i) Add allow joining album option in manage links
|
||||
- Neeraj: Fix lock error + improve logViewer
|
||||
- Laurens: text embedding caching for memories and discover
|
||||
- Neeraj: (i) Option to send qr for link
|
||||
- Neeraj: (i) Debug option to enable logViewer
|
||||
- Neeraj: Potential fix for ios in-app payment
|
||||
- Neeraj: Potential fix for ios in-app payment
|
||||
@@ -1,60 +0,0 @@
|
||||
# Log Viewer
|
||||
|
||||
A Flutter package that provides an in-app log viewer with advanced filtering capabilities for Ente apps.
|
||||
|
||||
## Features
|
||||
|
||||
- 📝 Real-time log capture and display
|
||||
- 🔍 Advanced filtering by logger name, log level, and text search
|
||||
- 🎨 Color-coded log levels for easy identification
|
||||
- 📊 SQLite-based storage with automatic truncation
|
||||
- 📤 Export filtered logs as text
|
||||
- ⚡ Performance optimized with batch inserts and indexing
|
||||
- 🏷️ Optional prefix support for multi-process logging
|
||||
|
||||
## Usage
|
||||
|
||||
### 1. Initialize in your app
|
||||
|
||||
```dart
|
||||
import 'package:log_viewer/log_viewer.dart';
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// Initialize log viewer (basic)
|
||||
await LogViewer.initialize();
|
||||
|
||||
// Or with a prefix for multi-process apps
|
||||
await LogViewer.initialize(prefix: '[MAIN]');
|
||||
|
||||
runApp(MyApp());
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Open the log viewer
|
||||
|
||||
```dart
|
||||
// As a navigation action
|
||||
LogViewer.openViewer(context);
|
||||
|
||||
// Or embed as a widget
|
||||
LogViewer.getViewerPage()
|
||||
```
|
||||
|
||||
### 3. Automatic log capture
|
||||
|
||||
The log viewer automatically registers with `Logger.root.onRecord` to capture all logs from the logging package. No additional setup is required.
|
||||
|
||||
## Filtering Options
|
||||
|
||||
- **Logger Name**: Filter by specific loggers (e.g., "auth", "sync", "ui")
|
||||
- **Log Level**: Filter by severity (SEVERE, WARNING, INFO, etc.)
|
||||
- **Text Search**: Search within log messages and error descriptions
|
||||
- **Time Range**: Filter logs by date/time range
|
||||
|
||||
## Database Management
|
||||
|
||||
- Logs are stored in a local SQLite database
|
||||
- By default, automatic truncation keeps only the most recent 10000 entries
|
||||
- Batch inserts for optimal performance
|
||||
@@ -1 +0,0 @@
|
||||
include: ../../analysis_options.yaml
|
||||
@@ -1,291 +0,0 @@
|
||||
# Log Viewer Integration Examples
|
||||
|
||||
This document provides examples of integrating the log_viewer package into your Flutter application, both as a standalone solution and integrated with SuperLogging.
|
||||
|
||||
## Standalone Integration (Without SuperLogging)
|
||||
|
||||
### Basic Setup
|
||||
|
||||
```dart
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:log_viewer/log_viewer.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// Initialize the log viewer
|
||||
await LogViewer.initialize();
|
||||
|
||||
// Set up logging
|
||||
Logger.root.level = Level.ALL;
|
||||
|
||||
// Log viewer automatically captures all logs - no manual setup needed!
|
||||
|
||||
runApp(MyApp());
|
||||
}
|
||||
|
||||
class MyApp extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'Log Viewer Example',
|
||||
home: MyHomePage(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class MyHomePage extends StatefulWidget {
|
||||
@override
|
||||
_MyHomePageState createState() => _MyHomePageState();
|
||||
}
|
||||
|
||||
class _MyHomePageState extends State<MyHomePage> {
|
||||
final Logger _logger = Logger('MyHomePage');
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('Log Viewer Example'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: Icon(Icons.bug_report),
|
||||
onPressed: () {
|
||||
// Navigate to log viewer
|
||||
LogViewer.openViewer(context);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
_logger.info('Info log message');
|
||||
},
|
||||
child: Text('Log Info'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
_logger.warning('Warning log message');
|
||||
},
|
||||
child: Text('Log Warning'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
try {
|
||||
throw Exception('Test error');
|
||||
} catch (e, s) {
|
||||
_logger.severe('Error occurred', e, s);
|
||||
}
|
||||
},
|
||||
child: Text('Log Error'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## SuperLogging Integration (Ente Photos Style)
|
||||
|
||||
### Complete Integration Example
|
||||
|
||||
```dart
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:log_viewer/log_viewer.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
// SuperLogging-like configuration
|
||||
class LogConfig {
|
||||
final String? logDirPath;
|
||||
final int maxLogFiles;
|
||||
final bool enableInDebugMode;
|
||||
final FutureOrVoidCallback? body;
|
||||
final String prefix;
|
||||
|
||||
LogConfig({
|
||||
this.logDirPath,
|
||||
this.maxLogFiles = 10,
|
||||
this.enableInDebugMode = false,
|
||||
this.body,
|
||||
this.prefix = "",
|
||||
});
|
||||
}
|
||||
|
||||
class SuperLogging {
|
||||
static final Logger _logger = Logger('SuperLogging');
|
||||
static late LogConfig config;
|
||||
|
||||
static Future<void> main([LogConfig? appConfig]) async {
|
||||
appConfig ??= LogConfig();
|
||||
SuperLogging.config = appConfig;
|
||||
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// Initialize log viewer in debug mode with prefix
|
||||
if (kDebugMode) {
|
||||
try {
|
||||
await LogViewer.initialize(prefix: appConfig.prefix);
|
||||
_logger.info("Log viewer initialized successfully");
|
||||
} catch (e) {
|
||||
_logger.warning("Failed to initialize log viewer: $e");
|
||||
}
|
||||
}
|
||||
|
||||
Logger.root.level = kDebugMode ? Level.ALL : Level.INFO;
|
||||
Logger.root.onRecord.listen(onLogRecord);
|
||||
|
||||
if (appConfig.body != null) {
|
||||
await appConfig.body!();
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> onLogRecord(LogRecord rec) async {
|
||||
final str = "${config.prefix} ${rec.toString()}";
|
||||
|
||||
// Print to console
|
||||
if (kDebugMode) {
|
||||
print(str);
|
||||
}
|
||||
|
||||
// Log viewer automatically captures all logs - no manual integration needed!
|
||||
}
|
||||
}
|
||||
|
||||
// Main application with SuperLogging integration
|
||||
Future<void> main() async {
|
||||
await SuperLogging.main(
|
||||
LogConfig(
|
||||
body: () async {
|
||||
runApp(MyApp());
|
||||
},
|
||||
logDirPath: (await getApplicationSupportDirectory()).path + "/logs",
|
||||
maxLogFiles: 5,
|
||||
enableInDebugMode: true,
|
||||
prefix: "[APP]",
|
||||
),
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Ente Photos Integration Example
|
||||
|
||||
In your Ente Photos app's main.dart or SuperLogging class, add the log viewer initialization:
|
||||
|
||||
```dart
|
||||
Future runWithLogs(Function() function, {String prefix = ""}) async {
|
||||
await SuperLogging.main(
|
||||
LogConfig(
|
||||
body: function,
|
||||
logDirPath: (await getApplicationSupportDirectory()).path + "/logs",
|
||||
maxLogFiles: 5,
|
||||
sentryDsn: kDebugMode ? sentryDebugDSN : sentryDSN,
|
||||
tunnel: sentryTunnel,
|
||||
enableInDebugMode: true,
|
||||
prefix: prefix,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 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");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Settings Page Integration
|
||||
|
||||
Add log viewer access in your settings page (debug mode only):
|
||||
|
||||
```dart
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:log_viewer/log_viewer.dart';
|
||||
|
||||
class SettingsPage extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Column(
|
||||
children: [
|
||||
// Existing email/user info with debug button
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'user@example.com',
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
),
|
||||
if (kDebugMode)
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
LogViewer.openViewer(context);
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Icon(
|
||||
Icons.bug_report,
|
||||
size: 20,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
// Other settings items...
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Features Available
|
||||
|
||||
Once integrated, users will have access to:
|
||||
|
||||
1. **Real-time log viewing** - Logs appear as they're generated
|
||||
2. **Filtering by log level** - Show only errors, warnings, info, etc.
|
||||
3. **Filtering by logger name** - Focus on specific components
|
||||
4. **Text search** - Search within log messages and errors
|
||||
5. **Date range filtering** - View logs from specific time periods
|
||||
6. **Export functionality** - Share logs as text files
|
||||
7. **Detailed view** - Tap any log to see full details including stack traces
|
||||
|
||||
## How It Works
|
||||
|
||||
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. 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 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
|
||||
|
||||
- Logs are buffered and batch-inserted for optimal performance
|
||||
- Database is indexed for fast filtering
|
||||
- UI updates are debounced to avoid excessive refreshes
|
||||
- Old logs are automatically cleaned up
|
||||
@@ -1,40 +0,0 @@
|
||||
# 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
|
||||
@@ -1,88 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:log_viewer/src/core/log_store.dart';
|
||||
import 'package:log_viewer/src/ui/log_viewer_page.dart';
|
||||
import 'package:logging/logging.dart' as log;
|
||||
|
||||
export 'src/core/log_database.dart';
|
||||
export 'src/core/log_models.dart';
|
||||
// Core exports
|
||||
export 'src/core/log_store.dart';
|
||||
export 'src/ui/log_detail_page.dart';
|
||||
export 'src/ui/log_filter_dialog.dart';
|
||||
export 'src/ui/log_list_tile.dart';
|
||||
// UI exports
|
||||
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({String prefix = ''}) async {
|
||||
if (_initialized) return;
|
||||
|
||||
_prefix = prefix;
|
||||
|
||||
// Initialize the log store
|
||||
await LogStore.instance.initialize();
|
||||
|
||||
// Register callback with super_logging if available
|
||||
_registerWithSuperLogging();
|
||||
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
/// Register callback with super_logging to receive logs
|
||||
static void _registerWithSuperLogging() {
|
||||
// Try to register with SuperLogging if available
|
||||
try {
|
||||
// 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, _prefix);
|
||||
});
|
||||
} catch (e) {
|
||||
// SuperLogging not available, fallback to direct logger
|
||||
log.Logger.root.onRecord.listen((record) {
|
||||
LogStore.addLogRecord(record, '');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the log viewer page widget
|
||||
static Widget getViewerPage() {
|
||||
if (!_initialized) {
|
||||
throw StateError(
|
||||
'LogViewer not initialized. Call LogViewer.initialize() first.',
|
||||
);
|
||||
}
|
||||
return const LogViewerPage();
|
||||
}
|
||||
|
||||
/// Open the log viewer in a new route
|
||||
static Future<void> openViewer(BuildContext context) async {
|
||||
if (!_initialized) {
|
||||
await initialize();
|
||||
}
|
||||
|
||||
await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const LogViewerPage(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Check if log viewer is initialized
|
||||
static bool get isInitialized => _initialized;
|
||||
|
||||
/// Dispose of log viewer resources
|
||||
static Future<void> dispose() async {
|
||||
if (_initialized) {
|
||||
await LogStore.instance.dispose();
|
||||
_initialized = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,423 +0,0 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:log_viewer/src/core/log_models.dart';
|
||||
import 'package:path/path.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
|
||||
/// Manages SQLite database for log storage
|
||||
class LogDatabase {
|
||||
static const String _databaseName = 'log_viewer.db';
|
||||
static const String _tableName = 'logs';
|
||||
static const int _databaseVersion = 1;
|
||||
|
||||
final int maxEntries;
|
||||
Database? _database;
|
||||
|
||||
LogDatabase({this.maxEntries = 10000});
|
||||
|
||||
/// Get database instance
|
||||
Future<Database> get database async {
|
||||
_database ??= await _initDatabase();
|
||||
return _database!;
|
||||
}
|
||||
|
||||
/// Initialize database
|
||||
Future<Database> _initDatabase() async {
|
||||
final documentsDirectory = await getApplicationDocumentsDirectory();
|
||||
final path = join(documentsDirectory.path, _databaseName);
|
||||
|
||||
return await openDatabase(
|
||||
path,
|
||||
version: _databaseVersion,
|
||||
onCreate: _onCreate,
|
||||
onOpen: _onOpen,
|
||||
);
|
||||
}
|
||||
|
||||
/// Create database tables
|
||||
Future<void> _onCreate(Database db, int version) async {
|
||||
await db.execute('''
|
||||
CREATE TABLE $_tableName(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
message TEXT NOT NULL,
|
||||
level TEXT NOT NULL,
|
||||
timestamp INTEGER NOT NULL,
|
||||
logger_name TEXT NOT NULL,
|
||||
error TEXT,
|
||||
stack_trace TEXT,
|
||||
process_prefix TEXT NOT NULL DEFAULT ''
|
||||
)
|
||||
''');
|
||||
|
||||
// Minimal indexes for write performance - only timestamp for ordering
|
||||
await db.execute(
|
||||
'CREATE INDEX idx_timestamp ON $_tableName(timestamp DESC)',
|
||||
);
|
||||
}
|
||||
|
||||
/// Called when database is opened
|
||||
Future<void> _onOpen(Database db) async {
|
||||
// Enable write-ahead logging for better performance
|
||||
// Use rawQuery for PRAGMA commands to avoid permission issues
|
||||
await db.rawQuery('PRAGMA journal_mode = WAL');
|
||||
}
|
||||
|
||||
/// Insert a single log entry
|
||||
Future<int> insertLog(LogEntry entry) async {
|
||||
final db = await database;
|
||||
final id = await db.insert(_tableName, entry.toMap());
|
||||
|
||||
// Auto-truncate if needed
|
||||
await _truncateIfNeeded(db);
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
/// Insert multiple log entries in a batch
|
||||
Future<void> insertLogs(List<LogEntry> entries) async {
|
||||
if (entries.isEmpty) return;
|
||||
|
||||
final db = await database;
|
||||
final batch = db.batch();
|
||||
|
||||
for (final entry in entries) {
|
||||
batch.insert(_tableName, entry.toMap());
|
||||
}
|
||||
|
||||
await batch.commit(noResult: true);
|
||||
await _truncateIfNeeded(db);
|
||||
}
|
||||
|
||||
/// Get logs with optional filtering
|
||||
Future<List<LogEntry>> getLogs({
|
||||
LogFilter? filter,
|
||||
int limit = 250,
|
||||
int offset = 0,
|
||||
}) async {
|
||||
final db = await database;
|
||||
|
||||
// Build WHERE clause
|
||||
final conditions = <String>[];
|
||||
final args = <dynamic>[];
|
||||
|
||||
if (filter != null) {
|
||||
// Logger filter
|
||||
if (filter.selectedLoggers.isNotEmpty) {
|
||||
final placeholders =
|
||||
List.filled(filter.selectedLoggers.length, '?').join(',');
|
||||
conditions.add('logger_name IN ($placeholders)');
|
||||
args.addAll(filter.selectedLoggers);
|
||||
}
|
||||
|
||||
// Level filter
|
||||
if (filter.selectedLevels.isNotEmpty) {
|
||||
final placeholders =
|
||||
List.filled(filter.selectedLevels.length, '?').join(',');
|
||||
conditions.add('level IN ($placeholders)');
|
||||
args.addAll(filter.selectedLevels);
|
||||
}
|
||||
|
||||
// Process prefix filter
|
||||
if (filter.selectedProcesses.isNotEmpty) {
|
||||
final placeholders =
|
||||
List.filled(filter.selectedProcesses.length, '?').join(',');
|
||||
conditions.add('process_prefix IN ($placeholders)');
|
||||
args.addAll(filter.selectedProcesses);
|
||||
}
|
||||
|
||||
// Search query
|
||||
if (filter.searchQuery != null && filter.searchQuery!.isNotEmpty) {
|
||||
conditions.add('(message LIKE ? OR error LIKE ?)');
|
||||
final searchPattern = '%${filter.searchQuery}%';
|
||||
args.add(searchPattern);
|
||||
args.add(searchPattern);
|
||||
}
|
||||
|
||||
// Time range
|
||||
if (filter.startTime != null) {
|
||||
conditions.add('timestamp >= ?');
|
||||
args.add(filter.startTime!.millisecondsSinceEpoch);
|
||||
}
|
||||
if (filter.endTime != null) {
|
||||
conditions.add('timestamp <= ?');
|
||||
args.add(filter.endTime!.millisecondsSinceEpoch);
|
||||
}
|
||||
}
|
||||
|
||||
final whereClause = conditions.isEmpty ? null : conditions.join(' AND ');
|
||||
|
||||
final results = await db.query(
|
||||
_tableName,
|
||||
where: whereClause,
|
||||
whereArgs: args.isEmpty ? null : args,
|
||||
orderBy:
|
||||
filter?.sortNewestFirst == false ? 'timestamp ASC' : 'timestamp DESC',
|
||||
limit: limit,
|
||||
offset: offset,
|
||||
);
|
||||
|
||||
return results.map((map) => LogEntry.fromMap(map)).toList();
|
||||
}
|
||||
|
||||
/// Get unique logger names for filtering
|
||||
Future<List<String>> getUniqueLoggers() async {
|
||||
final db = await database;
|
||||
final results = await db.rawQuery(
|
||||
'SELECT DISTINCT logger_name FROM $_tableName ORDER BY logger_name',
|
||||
);
|
||||
|
||||
return results.map((row) => row['logger_name'] as String).toList();
|
||||
}
|
||||
|
||||
/// Get unique process prefixes for filtering
|
||||
Future<List<String>> getUniqueProcesses() async {
|
||||
final db = await database;
|
||||
final results = await db.rawQuery(
|
||||
'SELECT DISTINCT process_prefix FROM $_tableName WHERE process_prefix != "" ORDER BY process_prefix',
|
||||
);
|
||||
|
||||
final prefixes =
|
||||
results.map((row) => row['process_prefix'] as String).toList();
|
||||
|
||||
// Always include 'Foreground' as an option for empty prefix
|
||||
final uniquePrefixes = <String>[''];
|
||||
uniquePrefixes.addAll(prefixes);
|
||||
|
||||
return uniquePrefixes;
|
||||
}
|
||||
|
||||
/// Get count of logs matching filter
|
||||
Future<int> getLogCount({LogFilter? filter}) async {
|
||||
final db = await database;
|
||||
|
||||
if (filter == null || !filter.hasActiveFilters) {
|
||||
final result =
|
||||
await db.rawQuery('SELECT COUNT(*) as count FROM $_tableName');
|
||||
return result.first['count'] as int;
|
||||
}
|
||||
|
||||
// Build WHERE clause (same as getLogs)
|
||||
final conditions = <String>[];
|
||||
final args = <dynamic>[];
|
||||
|
||||
if (filter.selectedLoggers.isNotEmpty) {
|
||||
final placeholders =
|
||||
List.filled(filter.selectedLoggers.length, '?').join(',');
|
||||
conditions.add('logger_name IN ($placeholders)');
|
||||
args.addAll(filter.selectedLoggers);
|
||||
}
|
||||
|
||||
if (filter.selectedLevels.isNotEmpty) {
|
||||
final placeholders =
|
||||
List.filled(filter.selectedLevels.length, '?').join(',');
|
||||
conditions.add('level IN ($placeholders)');
|
||||
args.addAll(filter.selectedLevels);
|
||||
}
|
||||
|
||||
if (filter.selectedProcesses.isNotEmpty) {
|
||||
final placeholders =
|
||||
List.filled(filter.selectedProcesses.length, '?').join(',');
|
||||
conditions.add('process_prefix IN ($placeholders)');
|
||||
args.addAll(filter.selectedProcesses);
|
||||
}
|
||||
|
||||
if (filter.searchQuery != null && filter.searchQuery!.isNotEmpty) {
|
||||
conditions.add('(message LIKE ? OR error LIKE ?)');
|
||||
final searchPattern = '%${filter.searchQuery}%';
|
||||
args.add(searchPattern);
|
||||
args.add(searchPattern);
|
||||
}
|
||||
|
||||
if (filter.startTime != null) {
|
||||
conditions.add('timestamp >= ?');
|
||||
args.add(filter.startTime!.millisecondsSinceEpoch);
|
||||
}
|
||||
if (filter.endTime != null) {
|
||||
conditions.add('timestamp <= ?');
|
||||
args.add(filter.endTime!.millisecondsSinceEpoch);
|
||||
}
|
||||
|
||||
final whereClause = conditions.join(' AND ');
|
||||
final query =
|
||||
'SELECT COUNT(*) as count FROM $_tableName WHERE $whereClause';
|
||||
final result = await db.rawQuery(query, args);
|
||||
|
||||
return result.first['count'] as int;
|
||||
}
|
||||
|
||||
/// Clear all logs
|
||||
Future<void> clearLogs() async {
|
||||
final db = await database;
|
||||
await db.delete(_tableName);
|
||||
}
|
||||
|
||||
/// Clear logs by logger name
|
||||
Future<void> clearLogsByLogger(String loggerName) async {
|
||||
final db = await database;
|
||||
await db.delete(
|
||||
_tableName,
|
||||
where: 'logger_name = ?',
|
||||
whereArgs: [loggerName],
|
||||
);
|
||||
}
|
||||
|
||||
/// Truncate old logs if over limit
|
||||
Future<void> _truncateIfNeeded(Database db) async {
|
||||
final countResult = await db.rawQuery(
|
||||
'SELECT COUNT(*) as count FROM $_tableName',
|
||||
);
|
||||
final count = countResult.first['count'] as int;
|
||||
|
||||
// When we reach 11k+ entries, keep only the last 10k
|
||||
if (count >= maxEntries + 1000) {
|
||||
final toDelete = count - maxEntries;
|
||||
|
||||
// Delete oldest entries
|
||||
await db.execute(
|
||||
'''
|
||||
DELETE FROM $_tableName
|
||||
WHERE id IN (
|
||||
SELECT id FROM $_tableName
|
||||
ORDER BY timestamp ASC
|
||||
LIMIT ?
|
||||
)
|
||||
''',
|
||||
[toDelete],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get logger statistics with count and percentage
|
||||
Future<List<LoggerStatistic>> getLoggerStatistics({LogFilter? filter}) async {
|
||||
final db = await database;
|
||||
|
||||
// Build WHERE clause (same as getLogs)
|
||||
final conditions = <String>[];
|
||||
final args = <dynamic>[];
|
||||
|
||||
if (filter != null) {
|
||||
if (filter.selectedLoggers.isNotEmpty) {
|
||||
final placeholders =
|
||||
List.filled(filter.selectedLoggers.length, '?').join(',');
|
||||
conditions.add('logger_name IN ($placeholders)');
|
||||
args.addAll(filter.selectedLoggers);
|
||||
}
|
||||
|
||||
if (filter.selectedLevels.isNotEmpty) {
|
||||
final placeholders =
|
||||
List.filled(filter.selectedLevels.length, '?').join(',');
|
||||
conditions.add('level IN ($placeholders)');
|
||||
args.addAll(filter.selectedLevels);
|
||||
}
|
||||
|
||||
if (filter.selectedProcesses.isNotEmpty) {
|
||||
final placeholders =
|
||||
List.filled(filter.selectedProcesses.length, '?').join(',');
|
||||
conditions.add('process_prefix IN ($placeholders)');
|
||||
args.addAll(filter.selectedProcesses);
|
||||
}
|
||||
|
||||
if (filter.searchQuery != null && filter.searchQuery!.isNotEmpty) {
|
||||
conditions.add('(message LIKE ? OR error LIKE ?)');
|
||||
final searchPattern = '%${filter.searchQuery}%';
|
||||
args.add(searchPattern);
|
||||
args.add(searchPattern);
|
||||
}
|
||||
|
||||
if (filter.startTime != null) {
|
||||
conditions.add('timestamp >= ?');
|
||||
args.add(filter.startTime!.millisecondsSinceEpoch);
|
||||
}
|
||||
if (filter.endTime != null) {
|
||||
conditions.add('timestamp <= ?');
|
||||
args.add(filter.endTime!.millisecondsSinceEpoch);
|
||||
}
|
||||
}
|
||||
|
||||
final whereClause =
|
||||
conditions.isEmpty ? '' : 'WHERE ${conditions.join(' AND ')}';
|
||||
|
||||
// Get total count for percentage calculation
|
||||
final totalQuery = 'SELECT COUNT(*) as total FROM $_tableName $whereClause';
|
||||
final totalResult = await db.rawQuery(totalQuery, args);
|
||||
final totalCount = totalResult.first['total'] as int;
|
||||
|
||||
if (totalCount == 0) return [];
|
||||
|
||||
// Get logger statistics using single optimized query
|
||||
final statsQuery = '''
|
||||
SELECT
|
||||
logger_name,
|
||||
COUNT(*) as count,
|
||||
(COUNT(*) * 100.0 / $totalCount) as percentage
|
||||
FROM $_tableName
|
||||
$whereClause
|
||||
GROUP BY logger_name
|
||||
ORDER BY count DESC
|
||||
''';
|
||||
|
||||
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,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Get time range of all logs
|
||||
Future<TimeRange?> getTimeRange() async {
|
||||
final db = await database;
|
||||
final result = await db.rawQuery('''
|
||||
SELECT
|
||||
MIN(timestamp) as min_timestamp,
|
||||
MAX(timestamp) as max_timestamp
|
||||
FROM $_tableName
|
||||
''');
|
||||
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Get all log timestamps for timeline visualization
|
||||
Future<List<DateTime>> getLogTimestamps() async {
|
||||
final db = await database;
|
||||
final result = await db.rawQuery('''
|
||||
SELECT timestamp
|
||||
FROM $_tableName
|
||||
ORDER BY timestamp ASC
|
||||
''');
|
||||
|
||||
return result
|
||||
.map(
|
||||
(row) => DateTime.fromMillisecondsSinceEpoch(
|
||||
row['timestamp'] as int,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Close database connection
|
||||
Future<void> close() async {
|
||||
final db = _database;
|
||||
if (db != null) {
|
||||
await db.close();
|
||||
_database = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,255 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Represents a single log entry
|
||||
class LogEntry {
|
||||
final int? id;
|
||||
final String message;
|
||||
final String level;
|
||||
final DateTime timestamp;
|
||||
final String loggerName;
|
||||
final String? error;
|
||||
final String? stackTrace;
|
||||
final String processPrefix;
|
||||
|
||||
LogEntry({
|
||||
this.id,
|
||||
required this.message,
|
||||
required this.level,
|
||||
required this.timestamp,
|
||||
required this.loggerName,
|
||||
this.error,
|
||||
this.stackTrace,
|
||||
this.processPrefix = '',
|
||||
});
|
||||
|
||||
/// Create from database map
|
||||
factory LogEntry.fromMap(Map<String, dynamic> map) {
|
||||
return LogEntry(
|
||||
id: map['id'] as int?,
|
||||
message: map['message'] as String,
|
||||
level: map['level'] as String,
|
||||
timestamp: DateTime.fromMillisecondsSinceEpoch(map['timestamp'] as int),
|
||||
loggerName: map['logger_name'] as String,
|
||||
error: map['error'] as String?,
|
||||
stackTrace: map['stack_trace'] as String?,
|
||||
processPrefix: map['process_prefix'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
/// Convert to database map
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
if (id != null) 'id': id,
|
||||
'message': message,
|
||||
'level': level,
|
||||
'timestamp': timestamp.millisecondsSinceEpoch,
|
||||
'logger_name': loggerName,
|
||||
'error': error,
|
||||
'stack_trace': stackTrace,
|
||||
'process_prefix': processPrefix,
|
||||
};
|
||||
}
|
||||
|
||||
/// Get color based on log level
|
||||
Color get levelColor {
|
||||
switch (level.toUpperCase()) {
|
||||
case 'SHOUT':
|
||||
case 'SEVERE':
|
||||
return Colors.red;
|
||||
case 'WARNING':
|
||||
return Colors.orange;
|
||||
case 'INFO':
|
||||
return Colors.blue;
|
||||
case 'CONFIG':
|
||||
return Colors.green;
|
||||
case 'FINE':
|
||||
case 'FINER':
|
||||
case 'FINEST':
|
||||
return Colors.grey;
|
||||
default:
|
||||
return Colors.black54;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get background color for list tile
|
||||
Color? get backgroundColor {
|
||||
switch (level.toUpperCase()) {
|
||||
case 'SHOUT':
|
||||
case 'SEVERE':
|
||||
return Colors.red.withValues(alpha: 0.1);
|
||||
case 'WARNING':
|
||||
return Colors.orange.withValues(alpha: 0.1);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Truncate message for preview
|
||||
String get truncatedMessage {
|
||||
final lines = message.split('\n');
|
||||
const maxLines = 4;
|
||||
|
||||
if (lines.length <= maxLines) {
|
||||
return message;
|
||||
}
|
||||
|
||||
return '${lines.take(maxLines).join('\n')}...';
|
||||
}
|
||||
|
||||
/// Format timestamp for display
|
||||
String get formattedTime {
|
||||
final hour = timestamp.hour.toString().padLeft(2, '0');
|
||||
final minute = timestamp.minute.toString().padLeft(2, '0');
|
||||
final second = timestamp.second.toString().padLeft(2, '0');
|
||||
final millis = timestamp.millisecond.toString().padLeft(3, '0');
|
||||
return '$hour:$minute:$second.$millis';
|
||||
}
|
||||
|
||||
/// Get display name for process prefix
|
||||
String get processDisplayName {
|
||||
if (processPrefix.isEmpty) {
|
||||
return 'Foreground';
|
||||
}
|
||||
// Remove square brackets if present (e.g., "[fbg]" -> "fbg")
|
||||
final cleanPrefix = processPrefix.replaceAll(RegExp(r'[\[\]]'), '');
|
||||
switch (cleanPrefix) {
|
||||
case 'fbg':
|
||||
return 'Firebase Background';
|
||||
default:
|
||||
return cleanPrefix.isEmpty ? 'Foreground' : cleanPrefix;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
final buffer = StringBuffer();
|
||||
buffer.writeln('[$formattedTime] [$loggerName] [$level]');
|
||||
buffer.writeln(message);
|
||||
if (error != null) {
|
||||
buffer.writeln('Error: $error');
|
||||
}
|
||||
if (stackTrace != null) {
|
||||
buffer.writeln('Stack trace:\n$stackTrace');
|
||||
}
|
||||
return buffer.toString();
|
||||
}
|
||||
}
|
||||
|
||||
/// Filter configuration for log queries
|
||||
class LogFilter {
|
||||
final Set<String> selectedLoggers;
|
||||
final Set<String> selectedLevels;
|
||||
final Set<String> selectedProcesses;
|
||||
final String? searchQuery;
|
||||
final DateTime? startTime;
|
||||
final DateTime? endTime;
|
||||
final bool sortNewestFirst;
|
||||
|
||||
const LogFilter({
|
||||
this.selectedLoggers = const {},
|
||||
this.selectedLevels = const {},
|
||||
this.selectedProcesses = const {},
|
||||
this.searchQuery,
|
||||
this.startTime,
|
||||
this.endTime,
|
||||
this.sortNewestFirst = true,
|
||||
});
|
||||
|
||||
/// Create a copy with modifications
|
||||
LogFilter copyWith({
|
||||
Set<String>? selectedLoggers,
|
||||
Set<String>? selectedLevels,
|
||||
Set<String>? selectedProcesses,
|
||||
String? searchQuery,
|
||||
DateTime? startTime,
|
||||
DateTime? endTime,
|
||||
bool? sortNewestFirst,
|
||||
bool clearSearchQuery = false,
|
||||
bool clearTimeFilter = false,
|
||||
}) {
|
||||
return LogFilter(
|
||||
selectedLoggers: selectedLoggers ?? this.selectedLoggers,
|
||||
selectedLevels: selectedLevels ?? this.selectedLevels,
|
||||
selectedProcesses: selectedProcesses ?? this.selectedProcesses,
|
||||
searchQuery: clearSearchQuery ? null : (searchQuery ?? this.searchQuery),
|
||||
startTime: clearTimeFilter ? null : (startTime ?? this.startTime),
|
||||
endTime: clearTimeFilter ? null : (endTime ?? this.endTime),
|
||||
sortNewestFirst: sortNewestFirst ?? this.sortNewestFirst,
|
||||
);
|
||||
}
|
||||
|
||||
/// Check if any filters are active
|
||||
bool get hasActiveFilters {
|
||||
return selectedLoggers.isNotEmpty ||
|
||||
selectedLevels.isNotEmpty ||
|
||||
selectedProcesses.isNotEmpty ||
|
||||
(searchQuery != null && searchQuery!.isNotEmpty) ||
|
||||
startTime != null ||
|
||||
endTime != null;
|
||||
}
|
||||
|
||||
/// Clear all filters
|
||||
static const LogFilter empty = LogFilter();
|
||||
}
|
||||
|
||||
/// Logger statistics data
|
||||
class LoggerStatistic {
|
||||
final String loggerName;
|
||||
final int logCount;
|
||||
final double percentage;
|
||||
|
||||
const LoggerStatistic({
|
||||
required this.loggerName,
|
||||
required this.logCount,
|
||||
required this.percentage,
|
||||
});
|
||||
|
||||
/// Alias for logCount for compatibility
|
||||
int get count => logCount;
|
||||
|
||||
/// Format percentage for display
|
||||
String get formattedPercentage {
|
||||
if (percentage >= 10) {
|
||||
return '${percentage.toStringAsFixed(1)}%';
|
||||
} else if (percentage >= 1) {
|
||||
return '${percentage.toStringAsFixed(1)}%';
|
||||
} else {
|
||||
return '${percentage.toStringAsFixed(2)}%';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Available log levels
|
||||
class LogLevels {
|
||||
static const List<String> all = [
|
||||
'ALL',
|
||||
'FINEST',
|
||||
'FINER',
|
||||
'FINE',
|
||||
'CONFIG',
|
||||
'INFO',
|
||||
'WARNING',
|
||||
'SEVERE',
|
||||
'SHOUT',
|
||||
'OFF',
|
||||
];
|
||||
|
||||
/// Get levels typically shown by default
|
||||
static const List<String> defaultVisible = [
|
||||
'INFO',
|
||||
'WARNING',
|
||||
'SEVERE',
|
||||
'SHOUT',
|
||||
];
|
||||
}
|
||||
|
||||
/// Represents a time range for logs
|
||||
class TimeRange {
|
||||
final DateTime start;
|
||||
final DateTime end;
|
||||
|
||||
const TimeRange({
|
||||
required this.start,
|
||||
required this.end,
|
||||
});
|
||||
}
|
||||
@@ -1,229 +0,0 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:log_viewer/src/core/log_database.dart';
|
||||
import 'package:log_viewer/src/core/log_models.dart';
|
||||
import 'package:logging/logging.dart' as log;
|
||||
|
||||
/// Singleton store that receives and manages logs
|
||||
class LogStore {
|
||||
static final LogStore _instance = LogStore._internal();
|
||||
static LogStore get instance => _instance;
|
||||
|
||||
LogStore._internal();
|
||||
|
||||
final LogDatabase _database = LogDatabase();
|
||||
final _logStreamController = StreamController<LogEntry>.broadcast();
|
||||
|
||||
// Buffer for batch inserts - optimized for small entries
|
||||
final List<LogEntry> _buffer = [];
|
||||
Timer? _flushTimer;
|
||||
static const int _bufferSize = 10;
|
||||
static const int _maxBufferSize = 200; // Safety limit
|
||||
|
||||
bool _initialized = false;
|
||||
bool get initialized => _initialized;
|
||||
|
||||
/// Stream of new log entries
|
||||
Stream<LogEntry> get logStream => _logStreamController.stream;
|
||||
|
||||
/// Initialize the log store
|
||||
Future<void> initialize() async {
|
||||
if (_initialized) return;
|
||||
|
||||
await _database.database; // Initialize database
|
||||
|
||||
// Start periodic flush timer - less frequent for better batching
|
||||
_flushTimer = Timer.periodic(
|
||||
const Duration(seconds: 15),
|
||||
(_) => _flush(),
|
||||
);
|
||||
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
/// Static method that super_logging.dart will call
|
||||
static void addLogRecord(log.LogRecord record, [String? processPrefix]) {
|
||||
if (_instance._initialized) {
|
||||
_instance._addLog(record, processPrefix ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a log from a LogRecord
|
||||
void _addLog(log.LogRecord record, String processPrefix) {
|
||||
final entry = LogEntry(
|
||||
message: record.message,
|
||||
level: record.level.name,
|
||||
timestamp: record.time,
|
||||
loggerName: record.loggerName,
|
||||
error: record.error?.toString(),
|
||||
stackTrace: record.stackTrace?.toString(),
|
||||
processPrefix: processPrefix,
|
||||
);
|
||||
|
||||
// Add to buffer for batch insert
|
||||
_buffer.add(entry);
|
||||
|
||||
// Emit to stream for real-time updates
|
||||
_logStreamController.add(entry);
|
||||
|
||||
// Flush when buffer reaches optimal size or safety limit
|
||||
if (_buffer.length >= _bufferSize) {
|
||||
_flush();
|
||||
} else if (_buffer.length >= _maxBufferSize) {
|
||||
// Emergency flush if buffer grows too large
|
||||
_flush();
|
||||
}
|
||||
}
|
||||
|
||||
/// Flush buffered logs to database
|
||||
Future<void> _flush() async {
|
||||
if (_buffer.isEmpty) return;
|
||||
|
||||
final toInsert = List<LogEntry>.from(_buffer);
|
||||
_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');
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/// Get logs with filtering
|
||||
Future<List<LogEntry>> getLogs({
|
||||
LogFilter? filter,
|
||||
int limit = 250,
|
||||
int offset = 0,
|
||||
}) async {
|
||||
// Flush any pending logs first
|
||||
await _flush();
|
||||
|
||||
return _database.getLogs(
|
||||
filter: filter,
|
||||
limit: limit,
|
||||
offset: offset,
|
||||
);
|
||||
}
|
||||
|
||||
/// Get unique logger names
|
||||
Future<List<String>> getLoggerNames() async {
|
||||
return _database.getUniqueLoggers();
|
||||
}
|
||||
|
||||
/// Get unique process prefixes
|
||||
Future<List<String>> getProcessNames() async {
|
||||
return _database.getUniqueProcesses();
|
||||
}
|
||||
|
||||
/// Get logger statistics with count and percentage
|
||||
Future<List<LoggerStatistic>> getLoggerStatistics({LogFilter? filter}) async {
|
||||
await _flush();
|
||||
return _database.getLoggerStatistics(filter: filter);
|
||||
}
|
||||
|
||||
/// Get count of logs matching filter
|
||||
Future<int> getLogCount({LogFilter? filter}) async {
|
||||
await _flush();
|
||||
return _database.getLogCount(filter: filter);
|
||||
}
|
||||
|
||||
/// Clear all logs
|
||||
Future<void> clearLogs() async {
|
||||
_buffer.clear();
|
||||
await _database.clearLogs();
|
||||
}
|
||||
|
||||
/// Clear logs by logger
|
||||
Future<void> clearLogsByLogger(String loggerName) async {
|
||||
_buffer.removeWhere((log) => log.loggerName == loggerName);
|
||||
await _database.clearLogsByLogger(loggerName);
|
||||
}
|
||||
|
||||
/// Export logs as text
|
||||
Future<String> exportLogs({LogFilter? filter}) async {
|
||||
final logs = await getLogs(filter: filter, limit: 10000);
|
||||
|
||||
final buffer = StringBuffer();
|
||||
buffer.writeln('=== Ente App Logs ===');
|
||||
buffer.writeln('Exported at: ${DateTime.now()}');
|
||||
if (filter != null && filter.hasActiveFilters) {
|
||||
buffer.writeln('Filters applied:');
|
||||
if (filter.selectedLoggers.isNotEmpty) {
|
||||
buffer.writeln(' Loggers: ${filter.selectedLoggers.join(', ')}');
|
||||
}
|
||||
if (filter.selectedLevels.isNotEmpty) {
|
||||
buffer.writeln(' Levels: ${filter.selectedLevels.join(', ')}');
|
||||
}
|
||||
if (filter.searchQuery != null && filter.searchQuery!.isNotEmpty) {
|
||||
buffer.writeln(' Search: ${filter.searchQuery}');
|
||||
}
|
||||
}
|
||||
buffer.writeln('Total logs: ${logs.length}');
|
||||
buffer.writeln('=' * 40);
|
||||
buffer.writeln();
|
||||
|
||||
for (final log in logs) {
|
||||
buffer.writeln(log.toString());
|
||||
buffer.writeln('-' * 40);
|
||||
}
|
||||
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
/// Get time range of all logs
|
||||
Future<TimeRange?> getTimeRange() async {
|
||||
await _flush();
|
||||
return _database.getTimeRange();
|
||||
}
|
||||
|
||||
/// Get all log timestamps for timeline visualization
|
||||
Future<List<DateTime>> getLogTimestamps() async {
|
||||
await _flush();
|
||||
return _database.getLogTimestamps();
|
||||
}
|
||||
|
||||
/// Export logs as JSON
|
||||
Future<String> exportLogsAsJson({LogFilter? filter}) async {
|
||||
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,
|
||||
},
|
||||
)
|
||||
.toList();
|
||||
|
||||
// Manual JSON formatting for readability
|
||||
final buffer = StringBuffer();
|
||||
buffer.writeln('[');
|
||||
for (int i = 0; i < jsonLogs.length; i++) {
|
||||
buffer.write(' ');
|
||||
buffer.write(jsonLogs[i].toString());
|
||||
if (i < jsonLogs.length - 1) {
|
||||
buffer.writeln(',');
|
||||
} else {
|
||||
buffer.writeln();
|
||||
}
|
||||
}
|
||||
buffer.writeln(']');
|
||||
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
/// Dispose resources
|
||||
Future<void> dispose() async {
|
||||
_flushTimer?.cancel();
|
||||
await _flush();
|
||||
await _database.close();
|
||||
await _logStreamController.close();
|
||||
_initialized = false;
|
||||
}
|
||||
}
|
||||
@@ -1,197 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:log_viewer/src/core/log_models.dart';
|
||||
|
||||
/// Detailed view of a single log entry
|
||||
class LogDetailPage extends StatelessWidget {
|
||||
final LogEntry log;
|
||||
|
||||
const LogDetailPage({
|
||||
super.key,
|
||||
required this.log,
|
||||
});
|
||||
|
||||
void _copyToClipboard(BuildContext context, String text, String label) {
|
||||
Clipboard.setData(ClipboardData(text: text));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('$label copied to clipboard'),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSection({
|
||||
required BuildContext context,
|
||||
required String title,
|
||||
required String content,
|
||||
bool isMonospace = true,
|
||||
}) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: theme.primaryColor,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.copy, size: 18),
|
||||
onPressed: () => _copyToClipboard(context, content, title),
|
||||
tooltip: 'Copy $title',
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.cardColor,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: theme.dividerColor,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: SelectableText(
|
||||
content,
|
||||
style: TextStyle(
|
||||
fontFamily: isMonospace ? 'monospace' : null,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoRow({
|
||||
required BuildContext context,
|
||||
required IconData icon,
|
||||
required String label,
|
||||
required String value,
|
||||
Color? valueColor,
|
||||
}) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, size: 20, color: theme.disabledColor),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
'$label: ',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
value,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: valueColor,
|
||||
fontWeight: valueColor != null ? FontWeight.bold : null,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Log Details'),
|
||||
elevation: 0,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.copy),
|
||||
onPressed: () => _copyToClipboard(
|
||||
context,
|
||||
log.toString(),
|
||||
'Complete log',
|
||||
),
|
||||
tooltip: 'Copy all',
|
||||
),
|
||||
],
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Log metadata
|
||||
Container(
|
||||
width: double.infinity,
|
||||
color: theme.appBarTheme.backgroundColor,
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Column(
|
||||
children: [
|
||||
_buildInfoRow(
|
||||
context: context,
|
||||
icon: Icons.access_time,
|
||||
label: 'Time',
|
||||
value: '${log.timestamp.toLocal()}',
|
||||
),
|
||||
_buildInfoRow(
|
||||
context: context,
|
||||
icon: Icons.flag,
|
||||
label: 'Level',
|
||||
value: log.level,
|
||||
valueColor: log.levelColor,
|
||||
),
|
||||
_buildInfoRow(
|
||||
context: context,
|
||||
icon: Icons.source,
|
||||
label: 'Logger',
|
||||
value: log.loggerName,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Message section
|
||||
_buildSection(
|
||||
context: context,
|
||||
title: 'MESSAGE',
|
||||
content: log.message,
|
||||
),
|
||||
|
||||
// Error section (if present)
|
||||
if (log.error != null)
|
||||
_buildSection(
|
||||
context: context,
|
||||
title: 'ERROR',
|
||||
content: log.error!,
|
||||
),
|
||||
|
||||
// Stack trace section (if present)
|
||||
if (log.stackTrace != null)
|
||||
_buildSection(
|
||||
context: context,
|
||||
title: 'STACK TRACE',
|
||||
content: log.stackTrace!,
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,449 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:log_viewer/src/core/log_models.dart';
|
||||
|
||||
/// Dialog for configuring log filters
|
||||
class LogFilterDialog extends StatefulWidget {
|
||||
final List<String> availableLoggers;
|
||||
final List<String> availableProcesses;
|
||||
final LogFilter currentFilter;
|
||||
|
||||
const LogFilterDialog({
|
||||
super.key,
|
||||
required this.availableLoggers,
|
||||
required this.availableProcesses,
|
||||
required this.currentFilter,
|
||||
});
|
||||
|
||||
@override
|
||||
State<LogFilterDialog> createState() => _LogFilterDialogState();
|
||||
}
|
||||
|
||||
class _LogFilterDialogState extends State<LogFilterDialog> {
|
||||
late Set<String> _selectedLoggers;
|
||||
late Set<String> _selectedLevels;
|
||||
late Set<String> _selectedProcesses;
|
||||
DateTime? _startTime;
|
||||
DateTime? _endTime;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_selectedLoggers = Set.from(widget.currentFilter.selectedLoggers);
|
||||
_selectedLevels = Set.from(widget.currentFilter.selectedLevels);
|
||||
_selectedProcesses = Set.from(widget.currentFilter.selectedProcesses);
|
||||
_startTime = widget.currentFilter.startTime;
|
||||
_endTime = widget.currentFilter.endTime;
|
||||
}
|
||||
|
||||
void _applyFilters() {
|
||||
final newFilter = LogFilter(
|
||||
selectedLoggers: _selectedLoggers,
|
||||
selectedLevels: _selectedLevels,
|
||||
selectedProcesses: _selectedProcesses,
|
||||
searchQuery: widget.currentFilter.searchQuery,
|
||||
startTime: _startTime,
|
||||
endTime: _endTime,
|
||||
);
|
||||
Navigator.pop(context, newFilter);
|
||||
}
|
||||
|
||||
void _clearFilters() {
|
||||
setState(() {
|
||||
_selectedLoggers.clear();
|
||||
_selectedLevels.clear();
|
||||
_selectedProcesses.clear();
|
||||
});
|
||||
}
|
||||
|
||||
Widget _buildLevelChip(String level) {
|
||||
final isSelected = _selectedLevels.contains(level);
|
||||
final color = LogEntry(
|
||||
message: '',
|
||||
level: level,
|
||||
timestamp: DateTime.now(),
|
||||
loggerName: '',
|
||||
).levelColor;
|
||||
|
||||
return FilterChip(
|
||||
label: Text(
|
||||
level,
|
||||
style: TextStyle(
|
||||
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) {
|
||||
_selectedLevels.add(level);
|
||||
} else {
|
||||
_selectedLevels.remove(level);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Dialog(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxWidth: 380, maxHeight: 500),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Header
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: const BoxDecoration(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Filter Logs',
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 18,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, size: 22),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Content
|
||||
Flexible(
|
||||
child: SingleChildScrollView(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Log Levels
|
||||
Text(
|
||||
'Log Levels',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 3,
|
||||
children: LogLevels.all
|
||||
.where((level) => level != 'ALL' && level != 'OFF')
|
||||
.map(_buildLevelChip)
|
||||
.toList(),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Process Prefixes
|
||||
if (widget.availableProcesses.isNotEmpty) ...[
|
||||
Text(
|
||||
'Process',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 15,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
constraints: const BoxConstraints(maxHeight: 120),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: theme.dividerColor.withValues(alpha: 0.5),
|
||||
width: 1,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
color: theme.cardColor,
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: ListView.separated(
|
||||
shrinkWrap: true,
|
||||
itemCount: widget.availableProcesses.length,
|
||||
separatorBuilder: (context, index) => Divider(
|
||||
height: 1,
|
||||
thickness: 0.5,
|
||||
color: theme.dividerColor.withValues(alpha: 0.3),
|
||||
),
|
||||
itemBuilder: (context, index) {
|
||||
final process = widget.availableProcesses[index];
|
||||
final isSelected =
|
||||
_selectedProcesses.contains(process);
|
||||
final displayName = LogEntry(
|
||||
message: '',
|
||||
level: 'INFO',
|
||||
timestamp: DateTime.now(),
|
||||
loggerName: '',
|
||||
processPrefix: process,
|
||||
).processDisplayName;
|
||||
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
if (isSelected) {
|
||||
_selectedProcesses.remove(process);
|
||||
} else {
|
||||
_selectedProcesses.add(process);
|
||||
}
|
||||
});
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
displayName,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: isSelected
|
||||
? theme.primaryColor
|
||||
: theme
|
||||
.textTheme.bodyLarge?.color,
|
||||
fontWeight: isSelected
|
||||
? FontWeight.w500
|
||||
: FontWeight.normal,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: Checkbox(
|
||||
value: isSelected,
|
||||
onChanged: (selected) {
|
||||
setState(() {
|
||||
if (selected == true) {
|
||||
_selectedProcesses.add(process);
|
||||
} else {
|
||||
_selectedProcesses
|
||||
.remove(process);
|
||||
}
|
||||
});
|
||||
},
|
||||
materialTapTargetSize:
|
||||
MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
|
||||
// Loggers
|
||||
if (widget.availableLoggers.isNotEmpty) ...[
|
||||
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),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Actions
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: theme.dividerColor.withValues(alpha: 0.2),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
borderRadius:
|
||||
const BorderRadius.vertical(bottom: Radius.circular(16)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: _clearFilters,
|
||||
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),
|
||||
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),
|
||||
FilledButton(
|
||||
onPressed: _applyFilters,
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:log_viewer/src/core/log_models.dart';
|
||||
|
||||
/// Individual log item widget
|
||||
class LogListTile extends StatelessWidget {
|
||||
final LogEntry log;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const LogListTile({
|
||||
super.key,
|
||||
required this.log,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return ListTile(
|
||||
onTap: onTap,
|
||||
tileColor: log.backgroundColor,
|
||||
leading: Container(
|
||||
width: 10,
|
||||
height: 10,
|
||||
decoration: BoxDecoration(
|
||||
color: log.levelColor,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
log.truncatedMessage,
|
||||
style: TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 13,
|
||||
color: theme.textTheme.bodyLarge?.color,
|
||||
),
|
||||
maxLines: 4,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.access_time,
|
||||
size: 12,
|
||||
color: theme.textTheme.bodySmall?.color,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
log.formattedTime,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: theme.textTheme.bodySmall?.color,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Icon(
|
||||
Icons.source,
|
||||
size: 12,
|
||||
color: theme.textTheme.bodySmall?.color,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Flexible(
|
||||
child: Text(
|
||||
log.loggerName,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: theme.textTheme.bodySmall?.color,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (log.error != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
size: 14,
|
||||
color: Colors.red[400],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
trailing: Icon(
|
||||
Icons.chevron_right,
|
||||
size: 20,
|
||||
color: theme.disabledColor,
|
||||
),
|
||||
visualDensity: VisualDensity.compact,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,715 +0,0 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:log_viewer/src/core/log_models.dart';
|
||||
import 'package:log_viewer/src/core/log_store.dart';
|
||||
import 'package:log_viewer/src/ui/log_detail_page.dart';
|
||||
import 'package:log_viewer/src/ui/log_filter_dialog.dart';
|
||||
import 'package:log_viewer/src/ui/log_list_tile.dart';
|
||||
import 'package:log_viewer/src/ui/logger_statistics_page.dart';
|
||||
import 'package:log_viewer/src/ui/timeline_widget.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
|
||||
/// Main log viewer page
|
||||
class LogViewerPage extends StatefulWidget {
|
||||
const LogViewerPage({super.key});
|
||||
|
||||
@override
|
||||
State<LogViewerPage> createState() => _LogViewerPageState();
|
||||
}
|
||||
|
||||
class _LogViewerPageState extends State<LogViewerPage> {
|
||||
final LogStore _logStore = LogStore.instance;
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
|
||||
List<LogEntry> _logs = [];
|
||||
List<String> _availableLoggers = [];
|
||||
List<String> _availableProcesses = [];
|
||||
LogFilter _filter = const LogFilter(
|
||||
selectedLevels: {'WARNING', 'SEVERE', 'SHOUT'},
|
||||
);
|
||||
bool _isLoading = true;
|
||||
bool _isLoadingMore = false;
|
||||
bool _hasMoreLogs = true;
|
||||
int _currentOffset = 0;
|
||||
static const int _pageSize = 100; // Load 100 logs at a time
|
||||
StreamSubscription<LogEntry>? _logStreamSubscription;
|
||||
|
||||
// Time filtering state
|
||||
bool _timeFilterEnabled = false;
|
||||
|
||||
// Timeline state
|
||||
DateTime? _overallStartTime;
|
||||
DateTime? _overallEndTime;
|
||||
DateTime? _timelineStartTime;
|
||||
DateTime? _timelineEndTime;
|
||||
List<DateTime> _logTimestamps = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initialize();
|
||||
}
|
||||
|
||||
Future<void> _initialize() async {
|
||||
await _loadLoggers();
|
||||
await _loadProcesses();
|
||||
await _initializeTimeline();
|
||||
await _loadLogs();
|
||||
|
||||
// Listen for new logs
|
||||
_logStreamSubscription = _logStore.logStream.listen((_) {
|
||||
// Debounce updates to avoid too frequent refreshes
|
||||
_scheduleRefresh();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _initializeTimeline() async {
|
||||
final timeRange = await _logStore.getTimeRange();
|
||||
if (timeRange != null) {
|
||||
setState(() {
|
||||
_overallStartTime = timeRange.start;
|
||||
_overallEndTime = timeRange.end;
|
||||
_timelineStartTime = timeRange.start;
|
||||
_timelineEndTime = timeRange.end;
|
||||
});
|
||||
}
|
||||
await _loadLogTimestamps();
|
||||
}
|
||||
|
||||
Future<void> _loadLogTimestamps() async {
|
||||
final timestamps = await _logStore.getLogTimestamps();
|
||||
setState(() {
|
||||
_logTimestamps = timestamps;
|
||||
});
|
||||
}
|
||||
|
||||
void _onTimelineRangeChanged(DateTime start, DateTime end) {
|
||||
setState(() {
|
||||
_timelineStartTime = start;
|
||||
_timelineEndTime = end;
|
||||
_filter = _filter.copyWith(
|
||||
startTime: start,
|
||||
endTime: end,
|
||||
);
|
||||
});
|
||||
_loadLogs();
|
||||
}
|
||||
|
||||
Timer? _refreshTimer;
|
||||
void _scheduleRefresh() {
|
||||
_refreshTimer?.cancel();
|
||||
_refreshTimer = Timer(const Duration(seconds: 1), () {
|
||||
if (mounted) {
|
||||
_loadLogs();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _loadLogs({bool reset = true}) async {
|
||||
if (reset) {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_currentOffset = 0;
|
||||
_hasMoreLogs = true;
|
||||
_logs.clear();
|
||||
});
|
||||
} else {
|
||||
setState(() => _isLoadingMore = true);
|
||||
}
|
||||
|
||||
try {
|
||||
final logs = await _logStore.getLogs(
|
||||
filter: _filter,
|
||||
limit: _pageSize,
|
||||
offset: _currentOffset,
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
if (reset) {
|
||||
_logs = logs;
|
||||
_isLoading = false;
|
||||
} else {
|
||||
_logs.addAll(logs);
|
||||
_isLoadingMore = false;
|
||||
}
|
||||
|
||||
_currentOffset += logs.length;
|
||||
_hasMoreLogs = logs.length == _pageSize;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_isLoadingMore = false;
|
||||
});
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Failed to load logs: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadMoreLogs() async {
|
||||
if (!_hasMoreLogs || _isLoadingMore) return;
|
||||
await _loadLogs(reset: false);
|
||||
}
|
||||
|
||||
Future<void> _loadLoggers() async {
|
||||
try {
|
||||
final loggers = await _logStore.getLoggerNames();
|
||||
if (mounted) {
|
||||
setState(() => _availableLoggers = loggers);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Failed to load logger names: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadProcesses() async {
|
||||
try {
|
||||
final processes = await _logStore.getProcessNames();
|
||||
if (mounted) {
|
||||
setState(() => _availableProcesses = processes);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Failed to load process names: $e');
|
||||
}
|
||||
}
|
||||
|
||||
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: searchText,
|
||||
clearSearchQuery: query.isEmpty,
|
||||
selectedLoggers: newLoggerFilters,
|
||||
);
|
||||
});
|
||||
_loadLogs();
|
||||
}
|
||||
|
||||
void _updateTimeFilter() {
|
||||
setState(() {
|
||||
if (_timeFilterEnabled &&
|
||||
_timelineStartTime != null &&
|
||||
_timelineEndTime != null) {
|
||||
_filter = _filter.copyWith(
|
||||
startTime: _timelineStartTime,
|
||||
endTime: _timelineEndTime,
|
||||
);
|
||||
} else {
|
||||
_filter = _filter.copyWith(
|
||||
clearTimeFilter: true,
|
||||
);
|
||||
}
|
||||
});
|
||||
_loadLogs();
|
||||
}
|
||||
|
||||
// String _formatTimeRange(double hours) {
|
||||
// if (hours < 1) {
|
||||
// final minutes = (hours * 60).round();
|
||||
// return '${minutes}m';
|
||||
// } else if (hours < 24) {
|
||||
// return '${hours.round()}h';
|
||||
// } else {
|
||||
// final days = (hours / 24).round();
|
||||
// return '${days}d';
|
||||
// }
|
||||
// }
|
||||
|
||||
Future<void> _showFilterDialog() async {
|
||||
final newFilter = await showDialog<LogFilter>(
|
||||
context: context,
|
||||
builder: (context) => LogFilterDialog(
|
||||
availableLoggers: _availableLoggers,
|
||||
availableProcesses: _availableProcesses,
|
||||
currentFilter: _filter,
|
||||
),
|
||||
);
|
||||
|
||||
if (newFilter != null && mounted) {
|
||||
setState(() => _filter = newFilter);
|
||||
await _loadLogs();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _clearLogs() async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Clear Logs'),
|
||||
content: const Text('Are you sure you want to clear all logs?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text('Clear', style: TextStyle(color: Colors.red)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true) {
|
||||
await _logStore.clearLogs();
|
||||
await _loadLogs();
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Logs cleared')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _exportLogs() async {
|
||||
try {
|
||||
final logText = await _logStore.exportLogs(filter: _filter);
|
||||
|
||||
await Share.share(logText, subject: 'App Logs');
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Failed to export logs: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _toggleSort() {
|
||||
setState(() {
|
||||
_filter = _filter.copyWith(
|
||||
sortNewestFirst: !_filter.sortNewestFirst,
|
||||
);
|
||||
});
|
||||
_loadLogs();
|
||||
}
|
||||
|
||||
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) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => LogDetailPage(log: log),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
_logStreamSubscription?.cancel();
|
||||
_refreshTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Logs'),
|
||||
elevation: 0,
|
||||
actions: [
|
||||
if (_filter.hasActiveFilters)
|
||||
IconButton(
|
||||
icon: Stack(
|
||||
children: [
|
||||
const Icon(Icons.filter_list),
|
||||
Positioned(
|
||||
right: 0,
|
||||
top: 0,
|
||||
child: Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.red,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
onPressed: _showFilterDialog,
|
||||
tooltip: 'Filters',
|
||||
)
|
||||
else
|
||||
IconButton(
|
||||
icon: const Icon(Icons.filter_list),
|
||||
onPressed: _showFilterDialog,
|
||||
tooltip: 'Filters',
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
_filter.sortNewestFirst
|
||||
? Icons.arrow_downward
|
||||
: Icons.arrow_upward,
|
||||
),
|
||||
onPressed: _toggleSort,
|
||||
tooltip: _filter.sortNewestFirst
|
||||
? 'Sort oldest first'
|
||||
: 'Sort newest first',
|
||||
),
|
||||
PopupMenuButton<String>(
|
||||
onSelected: (value) {
|
||||
switch (value) {
|
||||
case 'analytics':
|
||||
_showAnalytics();
|
||||
break;
|
||||
case 'clear':
|
||||
_clearLogs();
|
||||
break;
|
||||
case 'export':
|
||||
_exportLogs();
|
||||
break;
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
const PopupMenuItem(
|
||||
value: 'analytics',
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.analytics),
|
||||
title: Text('View Analytics'),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: 'export',
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.share),
|
||||
title: Text('Export Logs'),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: 'clear',
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.clear_all, color: Colors.red),
|
||||
title:
|
||||
Text('Clear Logs', style: TextStyle(color: Colors.red)),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
// Search bar
|
||||
Container(
|
||||
color: theme.appBarTheme.backgroundColor,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 6.0),
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Search logs...',
|
||||
hintStyle: const TextStyle(fontSize: 14),
|
||||
prefixIcon: const Icon(Icons.search, size: 20),
|
||||
suffixIcon: _searchController.text.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.clear, size: 20),
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
_onSearchChanged('');
|
||||
},
|
||||
)
|
||||
: null,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
isDense: true,
|
||||
),
|
||||
onChanged: _onSearchChanged,
|
||||
),
|
||||
),
|
||||
|
||||
// Timeline filter
|
||||
if (_overallStartTime != null && _overallEndTime != null) ...[
|
||||
Container(
|
||||
color: theme.appBarTheme.backgroundColor,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.timeline,
|
||||
size: 18,
|
||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Timeline Filter',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
_timeFilterEnabled
|
||||
? Icons.timeline
|
||||
: Icons.timeline_outlined,
|
||||
color: _timeFilterEnabled
|
||||
? theme.colorScheme.primary
|
||||
: theme.colorScheme.onSurface,
|
||||
),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_timeFilterEnabled = !_timeFilterEnabled;
|
||||
if (_timeFilterEnabled) {
|
||||
// Reset timeline to full range when enabled
|
||||
_timelineStartTime = _overallStartTime;
|
||||
_timelineEndTime = _overallEndTime;
|
||||
}
|
||||
});
|
||||
_updateTimeFilter();
|
||||
},
|
||||
tooltip: _timeFilterEnabled
|
||||
? 'Disable Timeline Filter'
|
||||
: 'Enable Timeline Filter',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_timeFilterEnabled) ...[
|
||||
TimelineWidget(
|
||||
startTime: _overallStartTime!,
|
||||
endTime: _overallEndTime!,
|
||||
currentStart: _timelineStartTime ?? _overallStartTime!,
|
||||
currentEnd: _timelineEndTime ?? _overallEndTime!,
|
||||
onTimeRangeChanged: _onTimelineRangeChanged,
|
||||
logTimestamps: _logTimestamps,
|
||||
),
|
||||
],
|
||||
],
|
||||
|
||||
// Active filters display
|
||||
if (_filter.hasActiveFilters)
|
||||
Container(
|
||||
height: 40,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: ListView(
|
||||
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();
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
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(
|
||||
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: '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();
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Log list
|
||||
Expanded(
|
||||
child: _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _logs.isEmpty
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.inbox,
|
||||
size: 64,
|
||||
color: theme.disabledColor,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_filter.hasActiveFilters
|
||||
? 'No logs match the current filters'
|
||||
: 'No logs available',
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
color: theme.disabledColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: RefreshIndicator(
|
||||
onRefresh: _loadLogs,
|
||||
child: ListView.separated(
|
||||
itemCount: _logs.length + (_hasMoreLogs ? 1 : 0),
|
||||
separatorBuilder: (context, index) =>
|
||||
index >= _logs.length
|
||||
? const SizedBox.shrink()
|
||||
: const Divider(height: 1),
|
||||
itemBuilder: (context, index) {
|
||||
// Show loading indicator at the bottom
|
||||
if (index >= _logs.length) {
|
||||
if (_isLoadingMore) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// Trigger loading more when reaching the end
|
||||
WidgetsBinding.instance
|
||||
.addPostFrameCallback((_) {
|
||||
_loadMoreLogs();
|
||||
});
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
}
|
||||
|
||||
final log = _logs[index];
|
||||
return LogListTile(
|
||||
log: log,
|
||||
onTap: () => _showLogDetail(log),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,314 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:log_viewer/src/core/log_models.dart';
|
||||
import 'package:log_viewer/src/core/log_store.dart';
|
||||
|
||||
/// Page showing logger statistics with percentage breakdown
|
||||
class LoggerStatisticsPage extends StatefulWidget {
|
||||
final LogFilter filter;
|
||||
|
||||
const LoggerStatisticsPage({
|
||||
super.key,
|
||||
required this.filter,
|
||||
});
|
||||
|
||||
@override
|
||||
State<LoggerStatisticsPage> createState() => _LoggerStatisticsPageState();
|
||||
}
|
||||
|
||||
class _LoggerStatisticsPageState extends State<LoggerStatisticsPage> {
|
||||
final LogStore _logStore = LogStore.instance;
|
||||
List<LoggerStatistic> _statistics = [];
|
||||
bool _isLoading = true;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadStatistics();
|
||||
}
|
||||
|
||||
Future<void> _loadStatistics() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final stats = await _logStore.getLoggerStatistics(filter: widget.filter);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_statistics = stats;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_error = e.toString();
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Color _getLoggerColor(int index, double percentage) {
|
||||
// Color coding based on percentage
|
||||
if (percentage > 50) return Colors.red.shade400;
|
||||
if (percentage > 20) return Colors.orange.shade400;
|
||||
if (percentage > 10) return Colors.blue.shade400;
|
||||
return Colors.green.shade400;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Logger Analytics'),
|
||||
elevation: 0,
|
||||
),
|
||||
body: _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _error != null
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
size: 48,
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Failed to load statistics',
|
||||
style: theme.textTheme.headlineSmall,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_error!,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton.icon(
|
||||
onPressed: _loadStatistics,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: _statistics.isEmpty
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.analytics_outlined,
|
||||
size: 64,
|
||||
color: theme.disabledColor,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No log data available',
|
||||
style: theme.textTheme.headlineSmall?.copyWith(
|
||||
color: theme.disabledColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: Column(
|
||||
children: [
|
||||
// Summary cards
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _SummaryCard(
|
||||
title: 'Total Logs',
|
||||
value: _statistics
|
||||
.fold(0, (sum, stat) => sum + stat.count)
|
||||
.toString(),
|
||||
icon: Icons.notes,
|
||||
color: Colors.blue,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: _SummaryCard(
|
||||
title: 'Loggers',
|
||||
value: _statistics.length.toString(),
|
||||
icon: Icons.category,
|
||||
color: Colors.green,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Statistics list
|
||||
Expanded(
|
||||
child: RefreshIndicator(
|
||||
onRefresh: _loadStatistics,
|
||||
child: ListView.builder(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 16),
|
||||
itemCount: _statistics.length,
|
||||
itemBuilder: (context, index) {
|
||||
final stat = _statistics[index];
|
||||
final color =
|
||||
_getLoggerColor(index, stat.percentage);
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SummaryCard extends StatelessWidget {
|
||||
final String title;
|
||||
final String value;
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
|
||||
const _SummaryCard({
|
||||
required this.title,
|
||||
required this.value,
|
||||
required this.icon,
|
||||
required this.color,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: color.withValues(alpha: 0.3),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
color: color,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
title,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: color,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
value,
|
||||
style: theme.textTheme.headlineMedium?.copyWith(
|
||||
color: color,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,295 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class TimelineWidget extends StatefulWidget {
|
||||
final DateTime startTime;
|
||||
final DateTime endTime;
|
||||
final DateTime currentStart;
|
||||
final DateTime currentEnd;
|
||||
final Function(DateTime start, DateTime end) onTimeRangeChanged;
|
||||
final List<DateTime> logTimestamps;
|
||||
|
||||
const TimelineWidget({
|
||||
super.key,
|
||||
required this.startTime,
|
||||
required this.endTime,
|
||||
required this.currentStart,
|
||||
required this.currentEnd,
|
||||
required this.onTimeRangeChanged,
|
||||
this.logTimestamps = const [],
|
||||
});
|
||||
|
||||
@override
|
||||
State<TimelineWidget> createState() => _TimelineWidgetState();
|
||||
}
|
||||
|
||||
class _TimelineWidgetState extends State<TimelineWidget> {
|
||||
late double _leftPosition;
|
||||
late double _rightPosition;
|
||||
bool _isDraggingLeft = false;
|
||||
bool _isDraggingRight = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_updatePositions();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(TimelineWidget oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.currentStart != widget.currentStart ||
|
||||
oldWidget.currentEnd != widget.currentEnd ||
|
||||
oldWidget.startTime != widget.startTime ||
|
||||
oldWidget.endTime != widget.endTime) {
|
||||
_updatePositions();
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
_leftPosition = startOffset / totalDuration;
|
||||
_rightPosition = endOffset / totalDuration;
|
||||
}
|
||||
|
||||
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 position = (localX / width).clamp(0.0, 1.0);
|
||||
|
||||
setState(() {
|
||||
if (isLeft) {
|
||||
_leftPosition = position.clamp(0.0, _rightPosition - 0.01);
|
||||
} else {
|
||||
_rightPosition = position.clamp(_leftPosition + 0.01, 1.0);
|
||||
}
|
||||
});
|
||||
|
||||
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),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Timeline Filter',
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Expanded(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return Stack(
|
||||
children: [
|
||||
// Timeline track
|
||||
Positioned(
|
||||
left: 20,
|
||||
right: 20,
|
||||
top: 20,
|
||||
bottom: 20,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.outline
|
||||
.withValues(alpha: 0.3),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: _buildLogDensityIndicator(
|
||||
constraints.maxWidth - 40,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Selected range
|
||||
Positioned(
|
||||
left: 20 + (_leftPosition * (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),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(
|
||||
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),
|
||||
child: Container(
|
||||
width: 20,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
color: _isDraggingLeft
|
||||
? theme.colorScheme.primary
|
||||
: theme.colorScheme.primary
|
||||
.withValues(alpha: 0.8),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.2),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Icon(
|
||||
Icons.drag_indicator,
|
||||
size: 12,
|
||||
color: theme.colorScheme.onPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 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),
|
||||
child: Container(
|
||||
width: 20,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
color: _isDraggingRight
|
||||
? theme.colorScheme.primary
|
||||
: theme.colorScheme.primary
|
||||
.withValues(alpha: 0.8),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.2),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Icon(
|
||||
Icons.drag_indicator,
|
||||
size: 12,
|
||||
color: theme.colorScheme.onPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// Time labels
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
_formatTime(widget.currentStart),
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
Text(
|
||||
_formatTime(widget.currentEnd),
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
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);
|
||||
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;
|
||||
return Expanded(
|
||||
child: Container(
|
||||
height: double.infinity,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 0.5),
|
||||
decoration: BoxDecoration(
|
||||
color:
|
||||
theme.colorScheme.primary.withValues(alpha: intensity * 0.6),
|
||||
borderRadius: BorderRadius.circular(1),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatTime(DateTime time) {
|
||||
return '${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}:${time.second.toString().padLeft(2, '0')}';
|
||||
}
|
||||
}
|
||||
@@ -1,482 +0,0 @@
|
||||
# Generated by pub
|
||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||
packages:
|
||||
async:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: async
|
||||
sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.13.0"
|
||||
boolean_selector:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: boolean_selector
|
||||
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
characters:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: characters
|
||||
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
clock:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: clock
|
||||
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
collection:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: collection
|
||||
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.19.1"
|
||||
cross_file:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: cross_file
|
||||
sha256: "7caf6a750a0c04effbb52a676dce9a4a592e10ad35c34d6d2d0e4811160d5670"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.3.4+2"
|
||||
crypto:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: crypto
|
||||
sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.6"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: fake_async
|
||||
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.3"
|
||||
ffi:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: ffi
|
||||
sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.4"
|
||||
file:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file
|
||||
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.1"
|
||||
fixnum:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: fixnum
|
||||
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
flutter:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_lints:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: flutter_lints
|
||||
sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.0.0"
|
||||
flutter_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_web_plugins:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
intl:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: intl
|
||||
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.20.2"
|
||||
leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker
|
||||
sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "10.0.9"
|
||||
leak_tracker_flutter_testing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker_flutter_testing
|
||||
sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.9"
|
||||
leak_tracker_testing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker_testing
|
||||
sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.1"
|
||||
lints:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: lints
|
||||
sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.1.1"
|
||||
logging:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: logging
|
||||
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.0"
|
||||
matcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: matcher
|
||||
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.12.17"
|
||||
material_color_utilities:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: material_color_utilities
|
||||
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.11.1"
|
||||
meta:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: meta
|
||||
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.16.0"
|
||||
mime:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: mime
|
||||
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
path:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: path
|
||||
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.9.1"
|
||||
path_provider:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: path_provider
|
||||
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.5"
|
||||
path_provider_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_android
|
||||
sha256: "993381400e94d18469750e5b9dcb8206f15bc09f9da86b9e44a9b0092a0066db"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.18"
|
||||
path_provider_foundation:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_foundation
|
||||
sha256: "16eef174aacb07e09c351502740fa6254c165757638eba1e9116b0a781201bbd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.2"
|
||||
path_provider_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_linux
|
||||
sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.1"
|
||||
path_provider_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_platform_interface
|
||||
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
path_provider_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_windows
|
||||
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.0"
|
||||
platform:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: platform
|
||||
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.6"
|
||||
plugin_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: plugin_platform_interface
|
||||
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.8"
|
||||
share_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: share_plus
|
||||
sha256: d7dc0630a923883c6328ca31b89aa682bacbf2f8304162d29f7c6aaff03a27a1
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "11.1.0"
|
||||
share_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: share_plus_platform_interface
|
||||
sha256: "88023e53a13429bd65d8e85e11a9b484f49d4c190abbd96c7932b74d6927cc9a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.1.0"
|
||||
sky_engine:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
source_span:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: source_span
|
||||
sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.10.1"
|
||||
sprintf:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sprintf
|
||||
sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.0"
|
||||
sqflite:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: sqflite
|
||||
sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.2"
|
||||
sqflite_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqflite_android
|
||||
sha256: "2b3070c5fa881839f8b402ee4a39c1b4d561704d4ebbbcfb808a119bc2a1701b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.1"
|
||||
sqflite_common:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqflite_common
|
||||
sha256: "6ef422a4525ecc601db6c0a2233ff448c731307906e92cabc9ba292afaae16a6"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.6"
|
||||
sqflite_darwin:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqflite_darwin
|
||||
sha256: "279832e5cde3fe99e8571879498c9211f3ca6391b0d818df4e17d9fff5c6ccb3"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.2"
|
||||
sqflite_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqflite_platform_interface
|
||||
sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.0"
|
||||
stack_trace:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: stack_trace
|
||||
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.12.1"
|
||||
stream_channel:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: stream_channel
|
||||
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.4"
|
||||
string_scanner:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: string_scanner
|
||||
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
synchronized:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: synchronized
|
||||
sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.4.0"
|
||||
term_glyph:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: term_glyph
|
||||
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.2"
|
||||
test_api:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.4"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: typed_data
|
||||
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
url_launcher_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_linux
|
||||
sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.1"
|
||||
url_launcher_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_platform_interface
|
||||
sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.2"
|
||||
url_launcher_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_web
|
||||
sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.1"
|
||||
url_launcher_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_windows
|
||||
sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.4"
|
||||
uuid:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: uuid
|
||||
sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.5.1"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_math
|
||||
sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.4"
|
||||
vm_service:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vm_service
|
||||
sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "15.0.0"
|
||||
web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: web
|
||||
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
win32:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: win32
|
||||
sha256: "66814138c3562338d05613a6e368ed8cfb237ad6d64a9e9334be3f309acfca03"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.14.0"
|
||||
xdg_directories:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: xdg_directories
|
||||
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
sdks:
|
||||
dart: ">=3.8.0 <4.0.0"
|
||||
flutter: ">=3.29.0"
|
||||
@@ -1,25 +0,0 @@
|
||||
name: log_viewer
|
||||
description: In-app log viewer with filtering capabilities for Ente apps
|
||||
version: 1.0.0
|
||||
|
||||
environment:
|
||||
sdk: ">=3.0.0 <4.0.0"
|
||||
flutter: ">=3.0.0"
|
||||
|
||||
dependencies:
|
||||
collection: ^1.18.0
|
||||
flutter:
|
||||
sdk: flutter
|
||||
intl: ^0.20.2
|
||||
logging: ^1.3.0 # For LogRecord type compatibility
|
||||
path: ^1.9.0
|
||||
path_provider: ^2.1.5
|
||||
share_plus: ^11.0.0 # For log export functionality
|
||||
sqflite: ^2.4.2
|
||||
|
||||
dev_dependencies:
|
||||
flutter_lints: ^5.0.0
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
|
||||
flutter:
|
||||
@@ -858,12 +858,7 @@ func runServer(environment string, server *gin.Engine) {
|
||||
|
||||
log.Fatal(server.RunTLS(":443", certPath, keyPath))
|
||||
} else {
|
||||
port := 8080
|
||||
if viper.IsSet("http.port") {
|
||||
port = viper.GetInt("http.port")
|
||||
}
|
||||
log.Infof("starting server on port %d", port)
|
||||
server.Run(fmt.Sprintf(":%d", port))
|
||||
server.Run(":8080")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -71,13 +71,8 @@ log-file: ""
|
||||
|
||||
# HTTP connection parameters
|
||||
http:
|
||||
# The port to bind to.
|
||||
# If not specified, defaults to 8080 (HTTP) or 443 (HTTPS with use-tls: true)
|
||||
# port: 8080
|
||||
|
||||
# If true, use TLS for HTTPS connections.
|
||||
# When true and port is not specified, defaults to port 443.
|
||||
# When false and port is not specified, defaults to port 8080.
|
||||
# If true, bind to 443 and use TLS.
|
||||
# By default, this is false, and museum will bind to 8080 without TLS.
|
||||
# use-tls: true
|
||||
|
||||
# Specify the base endpoints for various apps
|
||||
|
||||
@@ -3,7 +3,6 @@ package ente
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/ente-io/stacktrace"
|
||||
"golang.org/x/net/idna"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
@@ -140,17 +139,8 @@ func isValidDomainWithoutScheme(input string) error {
|
||||
if strings.Contains(trimmed, "://") {
|
||||
return NewBadRequestWithMessage("domain should not contain scheme (e.g., http:// or https://)")
|
||||
}
|
||||
|
||||
// Convert IDN to ASCII (Punycode) for validation
|
||||
asciiDomain, err := idna.ToASCII(trimmed)
|
||||
if err != nil {
|
||||
return NewBadRequestWithMessage(fmt.Sprintf("invalid idn domain format: %s", trimmed))
|
||||
}
|
||||
|
||||
// Validate the ASCII version
|
||||
if !domainRegex.MatchString(asciiDomain) {
|
||||
if !domainRegex.MatchString(trimmed) {
|
||||
return NewBadRequestWithMessage(fmt.Sprintf("invalid domain format: %s", trimmed))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -11,9 +11,7 @@ func TestIsValidDomainWithoutScheme(t *testing.T) {
|
||||
// ✅ Valid cases
|
||||
{"simple domain", "google.com", false},
|
||||
{"multi-level domain", "sub.example.co.in", false},
|
||||
{"multi-level domain", "photos.ä.com", false},
|
||||
{"numeric in label", "a1b2c3.com", false},
|
||||
{"idn", "テスト.jp", false},
|
||||
{"long but valid label", "my-very-long-subdomain-name.example.com", false},
|
||||
|
||||
// ❌ Leading/trailing spaces
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"golang.org/x/net/idna"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
@@ -32,7 +31,7 @@ import (
|
||||
)
|
||||
|
||||
var passwordWhiteListedURLs = []string{"/public-collection/info", "/public-collection/report-abuse", "/public-collection/verify-password"}
|
||||
var whitelistedCollectionShareIDs = []int64{111, 12172}
|
||||
var whitelistedCollectionShareIDs = []int64{111}
|
||||
|
||||
// CollectionLinkMiddleware intercepts and authenticates incoming requests
|
||||
type CollectionLinkMiddleware struct {
|
||||
@@ -192,9 +191,7 @@ func (m *CollectionLinkMiddleware) validatePassword(c *gin.Context, reqPath stri
|
||||
func (m *CollectionLinkMiddleware) validateOrigin(c *gin.Context, ownerID int64) error {
|
||||
origin := c.Request.Header.Get("Origin")
|
||||
|
||||
if origin == "" ||
|
||||
origin == viper.GetString("apps.public-albums") ||
|
||||
strings.HasSuffix(strings.ToLower(origin), "http://localhost:") {
|
||||
if origin == "" || origin == viper.GetString("apps.public-albums") {
|
||||
return nil
|
||||
}
|
||||
reqId := requestid.Get(c)
|
||||
@@ -221,26 +218,11 @@ func (m *CollectionLinkMiddleware) validateOrigin(c *gin.Context, ownerID int64)
|
||||
m.DiscordController.NotifyPotentialAbuse(alertMessage + " - originParseFailed")
|
||||
return nil
|
||||
}
|
||||
unicodeDomain, err := idna.ToUnicode(*domain)
|
||||
if err != nil {
|
||||
logger.WithError(err).Error("domainToUnicodeFailed")
|
||||
m.DiscordController.NotifyPotentialAbuse(alertMessage + " - domainToUnicodeFailed")
|
||||
return nil
|
||||
}
|
||||
|
||||
if !strings.Contains(strings.ToLower(parse.Host), strings.ToLower(*domain)) && !strings.Contains(strings.ToLower(parse.Host), strings.ToLower(unicodeDomain)) {
|
||||
logger.Warnf("domainMismatch: domain %s (unicode %s) vs originHost %s", *domain, unicodeDomain, parse.Host)
|
||||
if !strings.Contains(strings.ToLower(parse.Host), strings.ToLower(*domain)) {
|
||||
logger.Warnf("domainMismatch for owner %d, origin %s, domain %s host %s", ownerID, origin, *domain, parse.Host)
|
||||
m.DiscordController.NotifyPotentialAbuse(alertMessage + " - domainMismatch")
|
||||
return ente.NewPermissionDeniedError("unknown custom domain")
|
||||
}
|
||||
// Additional exact match check. In the future, remove the contains check above and only keep this exact match check.
|
||||
if !strings.EqualFold(parse.Host, *domain) && !strings.EqualFold(parse.Host, unicodeDomain) {
|
||||
logger.Warnf("exactDomainMismatch: domain %s (unicode %s) vs originHost %s", *domain, unicodeDomain, parse.Host)
|
||||
m.DiscordController.NotifyPotentialAbuse(alertMessage + " - exactDomainMismatch")
|
||||
// Do not return error here till we are fully sure that this won't cause any issues for existing
|
||||
// custom domains.
|
||||
// return ente.NewPermissionDeniedError("unknown custom domain")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -486,7 +486,7 @@
|
||||
"watch_folders": "Watch folders",
|
||||
"watched_folders": "Watched folders",
|
||||
"no_folders_added": "No folders added yet",
|
||||
"watch_folders_hint_1": "The folders you add here are monitored to automatically",
|
||||
"watch_folders_hint_1": "The folders you add here will monitored to automatically",
|
||||
"watch_folders_hint_2": "Upload new files to Ente",
|
||||
"watch_folders_hint_3": "Remove deleted files from Ente",
|
||||
"add_folder": "Add folder",
|
||||
|
||||
Reference in New Issue
Block a user