Compare commits

..

32 Commits

Author SHA1 Message Date
Neeraj
ad95b5bd2d [mob] Add album join option for internal users (#7147)
## Summary
• Adds album join toggle in manage links widget behind internal user
flag
• Dynamic permission levels: Viewers or Collaborators based on collect
setting

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

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

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

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

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

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

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

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

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

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

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

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

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

## Tests

Tested in debug mode on my pixel phone.
2025-09-10 18:01:53 +05:30
Laurens Priem
92c4b325ca Merge branch 'main' into text_embeddings_cache 2025-09-10 17:51:51 +05:30
laurenspriem
bc66c1519a put db method preference in claude 2025-09-10 17:49:54 +05:30
Neeraj
1e804d4829 Update internal changes log with new entries 2025-09-10 17:47:03 +05:30
laurenspriem
3a8c95123e Add database method best practice guideline
Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-10 17:42:31 +05:30
laurenspriem
54ad3e4abb Simplify getRepeatedTextEmbeddingCache method
Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-10 17:38:42 +05:30
laurenspriem
8e29a9e26b update internal change 2025-09-10 17:34:03 +05:30
laurenspriem
c691b545a2 Remove unnecessary cache lock from text embeddings service
Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-10 15:39:15 +05:30
laurenspriem
edcec3277e format 2025-09-10 15:37:30 +05:30
laurenspriem
cda3a5b149 Simplify text embeddings cache to use only database cache
Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-10 15:35:58 +05:30
laurenspriem
cc769fdd5b Remove assets folder 2025-09-10 15:20:24 +05:30
laurenspriem
b74fe86e87 Merge branch 'main' into text_embeddings_cache 2025-09-10 15:18:29 +05:30
laurenspriem
e420d7b86f Add text embeddings cache service
Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-10 14:24:08 +05:30
46 changed files with 3580 additions and 7456 deletions

113
CLAUDE.md
View File

@@ -1,113 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Ente is a fully open-source, end-to-end encrypted platform for storing data in the cloud. This monorepo contains:
- **Ente Photos**: End-to-end encrypted photo storage app (iOS/Android/Web/Desktop)
- **Ente Auth**: 2FA authenticator app with cloud backup
- Multiple client applications across platforms
- Museum: The Go backend server powering all services
## Repository Structure
- `/mobile/` - Flutter apps (Photos, Auth, Locker)
- `/web/` - Web applications (Next.js/React)
- `/desktop/` - Electron desktop app
- `/server/` - Museum backend (Go + PostgreSQL)
- `/cli/` - Command-line tools
- `/architecture/` - Technical documentation on E2E encryption
## Common Development Commands
### Web Development
```bash
cd web
yarn install # Install dependencies
yarn dev:photos # Run photos app on port 3000
yarn dev:auth # Run auth app on port 3003
yarn build:photos # Build photos for production
yarn lint # Run prettier, eslint, and tsc checks
yarn lint-fix # Auto-fix linting issues
```
### Mobile Development (Flutter)
```bash
cd mobile
melos bootstrap # Link packages and install dependencies
melos run:photos:apk # Run photos app on Android
melos build:photos:apk # Build release APK
melos clean:all # Clean all projects
flutter test # Run tests for current project
```
### Server Development (Museum)
```bash
cd server
docker compose up --build # Start local development cluster
go mod download # Download dependencies
go build -o museum ./cmd/museum # Build binary
docker compose down # Stop cluster
```
### Desktop Development
```bash
cd desktop
yarn install # Install dependencies
yarn dev # Start development server
yarn build # Build for production
yarn lint # Run linting checks
```
## Architecture
### End-to-End Encryption
All user data is encrypted client-side using:
- **Master Key**: Generated on signup, never leaves device unencrypted
- **Key Encryption Key**: Derived from user password
- **Collection Keys**: For folders/albums
- **File Keys**: Unique for each file
- Encryption uses libsodium (XSalsa20 + Poly1305 MAC)
### Technology Stack
- **Backend**: Go with Gin framework, PostgreSQL, Docker
- **Web**: Next.js, React, TypeScript, Yarn workspaces
- **Mobile**: Flutter 3.32.8, Dart, Melos for monorepo management
- **Desktop**: Electron, TypeScript
- **Infrastructure**: Docker, S3-compatible storage, multi-cloud replication
### API Communication
- Museum server at `localhost:8080` for local development
- Authentication via JWT tokens encrypted with user's public key
- All data transmitted is end-to-end encrypted
## Testing & Quality Checks
### Before Committing
- Run appropriate lint commands for the module you're working on
- Ensure TypeScript compilation succeeds (`yarn tsc` or `tsc`)
- For Flutter: Run `flutter analyze` and `flutter test`
- For Go: Run `go fmt ./...` and `go vet ./...`
### Code Style
- Follow existing patterns in neighboring files
- Use existing libraries rather than adding new dependencies
- Match the indentation and formatting style of existing code
- TypeScript/JavaScript: Prettier + ESLint configuration
- Flutter: Standard Dart formatting
- Go: Standard Go formatting
### Localization (Flutter)
- Add new strings to `/mobile/apps/photos/lib/l10n/intl_en.arb`
- Use `AppLocalizations` to access localized strings in code
- Example: `AppLocalizations.of(context).yourStringKey`
## Important Notes
- All sensitive operations happen client-side due to E2E encryption
- Never log or expose encryption keys, passwords, or auth tokens
- The server (Museum) cannot decrypt user data
- Follow security best practices for handling encrypted data
- When modifying encryption-related code, ensure backward compatibility
- **No analytics or tracking**: Never add any analytics, telemetry, or user tracking code

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,795 +0,0 @@
# Photo Swipe Culling Feature - Planning Document
## Design Screenshots
The following screenshots illustrate the feature design:
- `swipe_left_delete.png` - Left swipe interaction showing red overlay and trash icon for deletion
- `swipe_right_keep.png` - Right swipe interaction showing green overlay and thumbs up for keeping
- `group_carousel_view.png` - Main interface with group carousel at top and best picture badge
- `deletion_confirmation_dialog.png` - Final deletion confirmation dialog
## 1. Feature Overview
### Purpose
Transform the tedious task of removing duplicate/similar photos into an engaging, gamified experience using familiar swipe gestures inspired by dating apps like Tinder.
### Core Value Proposition
- **Efficiency**: Quick decision-making through intuitive gestures
- **Engagement**: Gamified experience reduces decision fatigue
- **Safety**: Batch processing with review capability before final deletion
- **Control**: Ability to navigate between groups and revise decisions
## 2. User Journey
### Entry Point
From Similar Images page → New icon in top-right corner (swipe/cards icon) → Opens swipe culling interface with selected images
- Icon only appears when filtered groups are available (not just when files are selected)
- Filters out single-image groups and groups with 50+ images before checking
- Carries over filtered groups from Similar Images page
### Flow
1. User selects images on Similar Images page (auto-selected duplicates)
2. Taps swipe culling icon to enter new interface
3. Reviews each image in a group via swipe gestures
4. Can navigate between groups using top carousel
5. Reviews deletion summary and confirms
## 3. UI/UX Design
### Screen Layout
#### Header
- **Left**: Back button
- **Center**: Empty
- **Right**: Delete button with:
- Red background color
- White text
- Trash icon at start
- Shows "Delete (N)" with pending count
#### Progress Indicator
- **Instagram-style dots** showing image progress within group
- **Location**: Just above the swipeable image card
- Positioned between group carousel and main image
- 8px padding above and below
- Center-aligned horizontally
- Subtle fade-in animation when switching groups
- **Color coding**:
- Red dots for deleted images
- Green dots for kept images
- Larger current dot (8px vs 6px)
- Gray for undecided
- **Max dots**: 10 (collapsed view for larger groups)
#### Main Content Area
- **Swipeable Card Stack**: Current image displayed prominently
- Shows full image without cropping (aspect ratio preserved)
- Card size adapts to image dimensions
- Uses full resolution image (not thumbnails) after initial load
- No "Best" badge (removed for v1)
- **Swipe Indicators**:
- Left swipe → Thin red border that intensifies with swipe distance (4px max)
- Right swipe → Thin green border that intensifies with swipe distance (4px max)
- Visual feedback: Only colored border, no full image overlay
- **File Information**: Directly below image (minimal gap):
- File display name (top)
- File size in human-readable format (bottom)
- Both in muted text color
- Positioned immediately after the image card
#### Group Navigation (Top Carousel)
- Horizontal scrollable list of image groups
- **Visual Design**:
- **Thumbnail shape**: Rectangular thumbnails (72x90px, portrait orientation)
- **Spacing**: Increased spacing between groups (8px horizontal padding)
- **Current group**: Two thumbnails stacked with slight rotation (like cards)
- Visible border around each thumbnail (1px solid stroke)
- Clear layering effect showing two distinct images
- **Other groups**: Single thumbnail of first image
- **Selection indicator**: Non-selected groups shown with reduced opacity
- **Completion badges**:
- Positioned ON the corner edge (not inside)
- Red badge with white text for deletion count
- Green circle with white checkmark for completed groups
- Both badges overlap the corner boundary
- **Interaction Model**:
- **Single tap on current group**: Show summary popup
- **Single tap on other group**: Navigate to that group
- **Long press**: Show popup summary with:
- Images kept vs deleted count
- Visual preview of decisions
- "Undo all" action for that group
- Storage to be freed from this group
#### Bottom Action Bar
- Positioned higher up from bottom edge
- **Design**:
- Large square containers for delete and keep buttons (72x72px)
- No container for undo button (standalone)
- Undo button positioned between the two containers
- **Left**: Delete button (X icon) in square container with elevation
- **Center**: Undo button with circular arrow icon (no container, muted color)
- **Right**: Keep button (thumbs up icon) in square container with elevation
### Interaction Patterns
#### Swipe Gestures
- **Right Swipe**: Mark image as "keep" (green indicator)
- **Left Swipe**: Mark image for deletion (red indicator)
- **Swipe Threshold**: ~30% of screen width to trigger action
- **Snap Back**: If swipe incomplete, card returns to center
#### Button Actions
- **Bottom Delete**: Alternative to left swipe
- **Bottom Keep**: Alternative to right swipe
- **Undo**: Reverts last swipe action within current group only
- **Group Undo**: Available via long-press on group in carousel (shows popup summary)
- **Confirm**: Opens deletion summary dialog
#### Auto-Advance Flow (Group Completion)
**Minimal Celebration Approach**: Ultra-quick, non-intrusive transition
1. **Duration**: Maximum 0.25-0.4s (half current time)
2. **Animation**: Light sprinkle effect or simple checkmark fade
3. **No text**: No "Group complete" message
4. **Smooth Transition**: Quick cross-fade to next group's first photo
5. **Non-blocking**: Animation doesn't prevent immediate interaction
**Alternative Approaches Considered**:
- Streak celebration with momentum carry-forward
- Level-up gaming style transitions
- Stories-style progress segments
- Swipe-through summary card
#### Special Cases
- **"Best Picture" Badge**: Removed for v1
- Future v2: Algorithm based on quality metrics, resolution, and filename patterns
- **Last Card in Group**: Auto-advances with celebration animation as described above
## 4. Feature Requirements
### Functional Requirements
#### Core Features
- [ ] Display images from selected similar groups in swipeable card interface
- [ ] Filter out single-image groups and groups with 50+ images
- [ ] Support swipe left (delete) and swipe right (keep) gestures
- [ ] Visual feedback during swipe (color overlays, icons)
- [ ] Track decisions per image (keep/delete/undecided)
- [ ] Group navigation carousel at top with image count badges
- [ ] Undo functionality for last action within current group
- [ ] Group-level undo via long-press popup
- [ ] Batch deletion and symlinking using existing `_deleteFilesLogic` from similar images page
- [ ] Progress tracking per group
- [ ] Auto-advance with minimal celebration animation between groups
#### Data Management
- [ ] Maintain decision state for each image
- [ ] Keep state in memory during session (no persistence in v1)
- [ ] Track full decision history for final deletion
- [ ] Track group-specific history for undo functionality
- [ ] Calculate and display deletion count
- [ ] Calculate storage to be freed
#### Navigation
- [ ] Entry from Similar Images page with selected files
- [ ] Exit handling (prompt if unsaved changes)
- [ ] Group switching via carousel
- [ ] Return to Similar Images after completion
### Non-Functional Requirements
#### Performance
- **Critical**: Smooth 60fps swipe animations (top priority)
- Display thumbnails first, then load full resolution images
- Preload next 2-3 images for instant display
- Lazy load group thumbnails in carousel
- Handle groups with 100+ images efficiently
- Memory efficiency through smart image recycling
#### User Experience
- Haptic feedback on swipe completion (if available)
- Clear visual states (undecided/keep/delete)
- Responsive to quick successive swipes
- Accessibility support for screen readers
## 5. Technical Architecture
### State Management
```dart
class SwipeCullingState {
List<SimilarFiles> groups;
int currentGroupIndex;
int currentImageIndex;
Map<EnteFile, SwipeDecision> decisions; // Global decisions
Map<int, List<SwipeAction>> groupHistories; // Per-group undo history
List<SwipeAction> fullHistory; // Complete history for final deletion
}
enum SwipeDecision { keep, delete, undecided }
class SwipeAction {
EnteFile file;
SwipeDecision decision;
DateTime timestamp;
int groupIndex;
}
```
### Key Components
#### SwipeCullingPage
- Main page widget managing overall state
- Handles navigation between groups
- Manages confirmation and deletion flow
#### SwipeablePhotoCard
- Individual card widget with swipe detection
- Handles gesture recognition and animation
- Renders image with overlay effects
#### GroupCarousel
- Horizontal scrollable group selector
- Shows thumbnails and progress badges
- Handles group switching
#### SwipeActionBar
- Bottom control buttons
- Triggers same actions as swipe gestures
- Manages undo stack
### Data Flow
1. Receive selected `List<SimilarFiles>` from Similar Images page
2. Filter out single-image groups and groups with 50+ images
3. Initialize decision map with all images as "undecided"
4. Update decisions based on user swipes
5. On confirm, filter images marked for deletion
6. Execute deletion using existing `_deleteFilesLogic` from Similar Images
- Includes symlink creation for collection preservation
- Handles bulk deletion with progress indicators
- Shows congratulations dialog for 100+ deletions
## 6. Implementation Phases
### Phase 1: Core Swipe Interface (MVP)
- Implement flutter_card_swiper for smooth animations
- Left/right swipe detection with visual feedback
- Color overlays and icons during swipe
- Single group support initially
- Basic confirm/delete flow
- Thumbnail-first image loading strategy
### Phase 2: Multi-Group Navigation
- Group carousel implementation
- Group switching logic
- Progress tracking per group
- Auto-advance between groups
### Phase 3: Polish & Optimization
- Smooth animations and transitions
- Haptic feedback
- Image preloading
- Performance optimization
- Undo functionality
### Phase 4: Advanced Features (Future)
- AI-powered "Best Picture" suggestions
- Bulk actions (delete all in group)
- Swipe sensitivity settings
- Statistics (photos reviewed, space saved)
## 7. Detailed Component Specifications
### SwipeablePhotoCard Widget
```dart
class SwipeablePhotoCard extends StatefulWidget {
final EnteFile file;
final VoidCallback onSwipeLeft; // Delete
final VoidCallback onSwipeRight; // Keep
final bool showBestPictureBadge;
}
```
**Behavior:**
- Displays image with proper aspect ratio
- Tracks finger position during drag
- Calculates swipe velocity and direction
- Shows overlay based on swipe direction
- Animates card exit on decision
- Returns to center if swipe incomplete
### GroupCarousel Widget
```dart
class GroupCarousel extends StatelessWidget {
final List<SimilarFiles> groups;
final int currentGroupIndex;
final Function(int) onGroupSelected;
final Map<SimilarFiles, GroupProgress> progressMap;
}
```
**Features:**
- 2x2 grid thumbnail for each group
- Clean thumbnails for unreviewd groups (no badges)
- **Red badge showing deletion count** for completed groups (only if > 0)
- Green checkmark for groups with all images kept
- Highlight current group with subtle border/glow
- Smooth scroll to selected group
- Long-press triggers popup with grid view and overlay indicators
### Confirmation Dialogs
#### Contextual Confirmations
1. **All-in-Group Deletion** (Shows immediately when user marks all images in a group for deletion):
- Title: "Delete all images in this group?"
- Message: "You've marked all X images for deletion. This will remove the entire group."
- Options: "Review Again" / "Confirm"
- Follow Ente dialog design patterns (use context explorer for reference)
2. **Final Batch Deletion** (When user taps Confirm button):
- "Delete images - Are you sure you want to delete all the images you swiped left on?"
- Shows total count and storage to be freed
- Options: "Delete" / "Cancel"
3. **No Additional Confirmation** needed when:
- Some (but not all) images in a group are marked for deletion
- User is just navigating between groups
- Using undo actions
## 8. Edge Cases & Considerations
### Edge Cases
- Single image groups: Not displayed in UI, filtered out completely
- User exits without confirming: Changes lost (no persistence in v1)
- Network/storage issues: Handled by existing bulk delete logic
- Large groups (50+ images): Hidden from UI in v1, not displayed
- Videos: Not applicable (Similar Images only handles photos)
- All images in group marked for deletion: Show immediate confirmation dialog
### Security & Privacy
- Maintain E2E encryption throughout
- No server-side processing of decisions
- Local-only gesture data
### Accessibility
- Alternative buttons for all swipe actions
- Screen reader support with clear descriptions
- Keyboard navigation support
- High contrast mode compatibility
## 9. Success Metrics
### User Engagement
- Time to review X images (target: <1 second per image)
- Completion rate (% users who finish review)
- Undo usage rate (indicates decision confidence)
### Feature Effectiveness
- Storage space reclaimed
- Number of duplicates removed
- User retention after using feature
## 10. Resolved Design Decisions
1. **Group Completion Behavior**: ✅ Auto-advance with minimal celebration animation
2. **Decision Persistence**: ✅ No persistence in v1 (add in future version)
3. **Best Picture Algorithm**: ✅ Use first image in group for v1
4. **Undo Scope**: ✅ Per-group undo history + group-level undo via long-press
5. **Animation Priority**: ✅ Smooth animations are critical, use flutter_card_swiper
6. **Single Image Groups**: ✅ Filter out completely, not shown in UI
7. **Large Groups**: ✅ Hide groups with 50+ images in v1
8. **Videos**: ✅ Not applicable (Similar Images is photos-only)
9. **Entry Point**: ✅ Icon only visible when images selected
10. **Deletion Logic**: ✅ Reuse existing `_deleteFilesLogic` with symlinks
## 11. Final Design Specifications
### Count Display Strategy
**Main Swipe Interface:**
- Header shows "X of Y" for current group only (subtle, non-intrusive)
- Optional: Progress dots below photo showing keep/delete pattern
**Carousel Groups:**
- Unreviewd: Clean thumbnails, no badges
- Current: Subtle highlight/border
- Completed: Red badge with deletion count (only if > 0)
- Alternative: Green checkmark if all kept
### Group Summary Popup (Long-press on carousel)
**Design**: Clean grid view with overlay indicators
- Shows all thumbnails in a grid layout
- Uses `EnteLoadingWidget` for thumbnails still loading
- No divider line above thumbnails
- Deleted images have red overlay with trash icon
- Kept images shown normally
- **Vertical button layout** (aligned, bottom of popup):
- "Delete These" button (critical style, top)
- "Undo All" button (secondary style, bottom)
- Shows storage to be freed at top
### Completion Flow
**All Groups Reviewed Dialog:**
- Appears after last group is completed
- Content:
- "All groups reviewed!"
- "X files marked for deletion"
- "Y MB will be freed"
- Actions:
- Primary: "Delete Files" button
- Secondary: "Review Again" button
- After deletion: Returns to Similar Images page
## 12. Dependencies
### Existing Components to Reuse
- `SimilarFiles` data model
- `EnteFile` model
- Deletion utilities with symlink support
- Image loading/caching system
- `ThumbnailWidget` for previews
### New Package Requirements
- **flutter_card_swiper** (Recommended: Best performance, active maintenance, undo support)
- Alternative: `appinio_swiper` for maximum memory efficiency
- Advanced animations (`flutter_animate`)
- Haptic feedback (`haptic_feedback`)
- Image optimization (`cached_network_image` with `flutter_cache_manager`)
## 13. Risk Assessment
### Technical Risks
- **Performance**: Smooth animations with high-res images
- **Memory**: Managing multiple images in memory
- **State Complexity**: Tracking decisions across multiple groups
### Mitigation Strategies
- Display thumbnails first, lazy load full resolution
- Use `flutter_card_swiper` with proper image caching
- Implement aggressive image recycling
- Simple, flat state structure with clear update patterns
- Preload strategically (next 2-3 images only)
- Consider WebP format for image compression
## 14. Testing Strategy
### Unit Tests
- Decision state management
- Undo/redo logic
- Group navigation logic
### Widget Tests
- Swipe gesture recognition
- Animation states
- Button interactions
### Integration Tests
- Full flow from Similar Images to deletion
- State persistence
- Error handling
### User Testing
- A/B test auto-advance vs manual navigation
- Test swipe sensitivity settings
- Gather feedback on animation speed
## 15. Documentation Needs
### User Documentation
- Tutorial on first use
- Gesture guide
- FAQ section
### Developer Documentation
- State management architecture
- Component interaction diagrams
- Animation timing specifications
## 16. Future Enhancements
### V2 Features (Next Release)
- **Decision Persistence**: Save swipe decisions across sessions
- **Smart Best Picture Algorithm**:
- Technical quality metrics (resolution, blur, exposure)
- Filename pattern analysis (avoid "Copy" versions)
- ML-based composition analysis
- **Batch Group Operations**: "Delete all except first" quick action
- **Advanced Statistics**: Photos reviewed, space saved, time spent
### V3 Features (Future)
- Advanced filters (by date, size, etc.)
- ML-powered quality detection with learning
- Face recognition priority
- Auto-grouping by events
- Collaborative culling (family shared albums)
- Cloud backup of decision history
- Swipe sensitivity customization
## 17. Implementation Plan
### File Structure
```
mobile/apps/photos/lib/ui/
├── pages/
│ └── library_culling/
│ ├── swipe_culling_page.dart
│ ├── widgets/
│ │ ├── swipeable_photo_card.dart
│ │ ├── group_carousel.dart
│ │ ├── swipe_action_bar.dart
│ │ └── group_summary_popup.dart
│ └── models/
│ └── swipe_culling_state.dart
```
### State Management
- Use `StatefulWidget` with `setState` for main page state
- Keep state simple and isolated to this feature
- No new dependencies required
### Navigation & Data Passing
- Pass selected `List<SimilarFiles>` via constructor from Similar Images page
- Return result (deleted count) via `Navigator.pop(result)`
### Key Implementation Steps
#### Step 1: Create Base Structure
1. Create folder structure under `lib/ui/pages/library_culling/`
2. Create `SwipeCullingPage` StatefulWidget
3. Define `SwipeCullingState` model class
4. Set up basic navigation from Similar Images page
#### Step 2: Add Entry Point Icon
1. In `similar_images_page.dart`, add icon to AppBar actions
2. Show icon only when `selectedFiles.isNotEmpty`
3. Icon navigates to `SwipeCullingPage` with selected groups
4. Use `Icons.view_carousel_rounded` icon
#### Step 3: Implement Core Swipe Interface
1. Add `flutter_card_swiper` to pubspec.yaml
2. Create `SwipeablePhotoCard` widget
3. Implement swipe detection and visual feedback
4. Track decisions in state map
5. Test with single group first
#### Step 4: Build UI Components
1. **Header**: Back button, "X of Y" counter, Confirm button
2. **GroupCarousel**: Horizontal list with thumbnails and badges
3. **SwipeActionBar**: Delete/Undo/Keep buttons
4. **Swipe overlays**: Red/green borders with icons
#### Step 5: Implement Group Navigation
1. Add carousel widget with tap/long-press handlers
2. Implement group switching logic
3. Add progress tracking per group
4. Implement auto-advance with minimal celebration using Ente color scheme
#### Step 6: Add Popup Interactions
1. Create `GroupSummaryPopup` for long-press
2. Show grid with overlay indicators
3. Add "Undo All" and "Delete These" actions
4. Calculate and display storage savings
#### Step 7: Duplicate & Adapt Deletion Logic
1. Copy `_deleteFilesLogic` from `similar_images_page.dart`
2. Adapt for swipe culling context
3. Maintain symlink creation logic
4. Add progress indicators
#### Step 8: Implement Completion Flow
1. Detect when all groups reviewed
2. Show "All groups reviewed!" dialog
3. Display deletion summary (count + storage)
4. Execute batch deletion on confirmation
5. Navigate back to Similar Images page
#### Step 9: Add Localization
1. Add strings to `/mobile/apps/photos/lib/l10n/intl_en.arb`:
```json
"swipeToReview": "Swipe to Review",
"imageXOfY": "{current} of {total}",
"allGroupsReviewed": "All groups reviewed!",
"filesMarkedForDeletion": "{count} files marked for deletion",
"storageToBeFreed": "{size} will be freed",
"deleteFiles": "Delete Files",
"reviewAgain": "Review Again",
"deleteThese": "Delete These",
"undoAll": "Undo All",
"groupComplete": "Group complete",
"deleteAllInGroup": "Delete all images in this group?",
"allImagesMarkedForDeletion": "You've marked all {count} images for deletion"
```
2. Use via `AppLocalizations.of(context).stringKey`
#### Step 10: Handle Edge Cases
1. Filter out single-image groups
2. Filter out groups with 50+ images
3. Implement confirmation for all-in-group deletion
4. Handle exit without saving (show warning dialog)
#### Step 11: Performance Optimization
1. Use `ThumbnailWidget` for initial display
2. Lazy load full resolution images
3. Preload next 2-3 images
4. Implement image recycling
5. Test with large datasets
#### Step 12: Testing
1. Unit tests for state management logic
2. Widget tests for swipe detection
3. Integration test for full flow
4. Manual testing on physical devices
5. Performance profiling
### Development Order
1. **Day 1**: Steps 1-4 (Base structure + core swipe)
2. **Day 2**: Steps 5-6 (Group navigation + popups)
3. **Day 3**: Steps 7-8 (Deletion logic + completion)
4. **Day 4**: Steps 9-10 (Localization + edge cases)
5. **Day 5**: Steps 11-12 (Optimization + testing)
### Code Snippets
#### Navigation from Similar Images
```dart
// In similar_images_page.dart AppBar actions
if (selectedFiles.isNotEmpty)
IconButton(
icon: Icon(Icons.view_carousel_rounded),
onPressed: () async {
final result = await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => SwipeCullingPage(
similarFiles: selectedFiles,
),
),
);
if (result != null && result > 0) {
// Refresh page after deletion
_loadSimilarFiles();
}
},
),
```
#### Basic State Structure
```dart
class SwipeCullingPage extends StatefulWidget {
final List<SimilarFiles> similarFiles;
const SwipeCullingPage({
Key? key,
required this.similarFiles,
}) : super(key: key);
@override
State<SwipeCullingPage> createState() => _SwipeCullingPageState();
}
class _SwipeCullingPageState extends State<SwipeCullingPage> {
late List<SimilarFiles> groups;
int currentGroupIndex = 0;
int currentImageIndex = 0;
Map<EnteFile, SwipeDecision> decisions = {};
Map<int, List<SwipeAction>> groupHistories = {};
@override
void initState() {
super.initState();
// Filter groups (no singles, no 50+)
groups = widget.similarFiles
.where((g) => g.files.length > 1 && g.files.length < 50)
.toList();
// Initialize all as undecided
for (final group in groups) {
for (final file in group.files) {
decisions[file] = SwipeDecision.undecided;
}
}
}
// ... rest of implementation
}
```
### Testing Checklist
- [ ] Swipe gestures work smoothly
- [ ] Visual feedback appears correctly
- [ ] Group navigation works
- [ ] Undo functionality works within groups
- [ ] Long-press popup displays correctly
- [ ] Deletion logic preserves symlinks (same as similar_images_page.dart)
- [ ] Completion flow shows summary
- [ ] All edge cases handled
- [ ] Performance acceptable with many images
- [ ] Localization works correctly
- [ ] No analytics or tracking code present
### Key Implementation Notes
1. **Icon**: Use `Icons.view_carousel_rounded` for entry point
2. **Header Button**: Shows "Delete (N)" not "Confirm (N)"
3. **Celebration Animation**: Simple, minimal, using Ente colorScheme
4. **Deletion Logic**: Exact copy from `_deleteFilesLogic` in similar_images_page.dart
5. **No Analytics**: Never add any tracking or telemetry code

View File

@@ -1,335 +0,0 @@
# Flicker Fix Attempts - Post-Swipe Animation Issue
This document tracks all attempted solutions to fix the slight flicker that occurs right after swiping an image in the photo swipe culling interface.
## Problem Description
After swiping an image (left or right), there's a brief flicker that occurs at the end of the swipe animation. The animation itself is smooth, but there's a visual glitch right as it completes and the next card comes into view.
## Attempted Solutions
### Attempt 1: Stabilize CardSwiper Key (FAILED)
**Theory**: The flicker was caused by the CardSwiper widget rebuilding due to a changing key that included `currentImageIndex`.
**Changes Made**:
```dart
// Before
key: ValueKey('swiper_${currentGroupIndex}_$currentImageIndex'),
// After
key: ValueKey('swiper_$currentGroupIndex'),
```
**Result**: No improvement - flicker still present.
**Why it failed**: The key wasn't the root cause of the flicker issue.
### Attempt 2: Minimize setState and Separate Data Updates (FAILED - CAUSED REGRESSION)
**Theory**: The flicker was caused by frequent setState calls rebuilding the entire CardSwiper widget. By updating data outside setState and only triggering minimal UI updates, the CardSwiper could maintain its internal animation state.
**Changes Made**:
```dart
void _handleSwipeDecision(SwipeDecision decision) {
// ... existing code ...
// Update decisions without setState to avoid rebuilding CardSwiper
decisions[file] = decision;
// ... update other data ...
// Only trigger setState for UI elements that need to update (not CardSwiper)
setState(() {
// This minimal setState updates progress dots, file info, etc.
});
}
```
**Result**: Made the issue worse and caused regressions in functionality.
**Why it failed**: The approach broke the normal Flutter state management flow and caused UI inconsistencies.
### Attempt 3: Stable CardSwiper Key and Fixed cardsCount (FAILED)
**Theory**: The flicker was caused by the CardSwiper widget being rebuilt every time `currentImageIndex` changed due to:
1. The key including `currentImageIndex`: `ValueKey('swiper_${currentGroupIndex}_$currentImageIndex')`
2. The `cardsCount` changing: `currentGroupFiles.length - currentImageIndex`
**Changes Made**:
```dart
// Before
key: ValueKey('swiper_${currentGroupIndex}_$currentImageIndex'),
cardsCount: currentGroupFiles.length - currentImageIndex,
numberOfCardsDisplayed: (currentGroupFiles.length - currentImageIndex).clamp(1, 4),
// After
key: ValueKey('swiper_$currentGroupIndex'), // Removed currentImageIndex
cardsCount: currentGroupFiles.length, // Fixed to full group size
numberOfCardsDisplayed: currentGroupFiles.length.clamp(1, 4),
// Updated cardBuilder to skip already-swiped cards
if (index < currentImageIndex) {
return const SizedBox.shrink();
}
// Added swipe validation
if (previousIndex != currentImageIndex) {
return false; // Reject out-of-order swipes
}
```
**Result**: No improvement - flicker still present, exactly the same behavior.
**Why it failed**: While the theory was sound (preventing widget rebuilds should eliminate flicker), the issue appears to be deeper within the CardSwiper package's internal animation handling or the interaction between Flutter's widget tree and the card animations.
**Additional Issues Encountered**:
- Initially caused assertion error: `numberOfCardsDisplayed` must be ≤ `cardsCount`
- Required clamping: `numberOfCardsDisplayed: currentGroupFiles.length.clamp(1, 4)`
- Complex logic needed to handle already-swiped cards in cardBuilder
### Attempt 4: Stable Key with SizedBox.shrink() for Swiped Cards (FAILED)
**Theory**: The flicker was caused by CardSwiper rebuilding with dynamic key and cardsCount. By using a stable key and handling already-swiped cards in the cardBuilder, the widget should maintain its internal state.
**Changes Made**:
```dart
// Before
key: ValueKey('swiper_${currentGroupIndex}_$currentImageIndex'),
cardsCount: currentGroupFiles.length - currentImageIndex,
// After
key: ValueKey('swiper_$currentGroupIndex'), // Stable key
cardsCount: currentGroupFiles.length, // Fixed count
// Updated cardBuilder to skip swiped cards
if (index < currentImageIndex) {
return const SizedBox.shrink();
}
// Updated swipe detection logic
final isSwipingLeft = index == currentImageIndex && swipeProgress < -0.1;
// Added swipe validation
if (previousIndex != currentImageIndex) {
return false; // Reject out-of-order swipes
}
```
**Result**: No improvement - flicker still present. The approach failed to eliminate the visual glitch.
**Why it failed**: Using `SizedBox.shrink()` for already-swiped cards may still cause the CardSwiper's internal layout calculations to be affected. The package might still be rebuilding internal widget trees or animation controllers despite the stable key.
### Attempt 5: RepaintBoundary Around Individual Cards (FAILED)
**Theory**: The flicker was caused by unnecessary repaints propagating through the widget tree. By wrapping each SwipeablePhotoCard in a RepaintBoundary, each card would have its own compositing layer and prevent paint operations from affecting other cards or the parent CardSwiper.
**Changes Made**:
```dart
// Before
return SwipeablePhotoCard(
key: ValueKey(file.uploadedFileID ?? file.localID),
file: file,
swipeProgress: swipeProgress,
isSwipingLeft: isSwipingLeft,
isSwipingRight: isSwipingRight,
showFileInfo: false,
);
// After
return RepaintBoundary(
child: SwipeablePhotoCard(
key: ValueKey(file.uploadedFileID ?? file.localID),
file: file,
swipeProgress: swipeProgress,
isSwipingLeft: isSwipingLeft,
isSwipingRight: isSwipingRight,
showFileInfo: false,
),
);
```
**Result**: No improvement - flicker still present exactly as before.
**Why it failed**: The flicker appears to be unrelated to painting optimization. RepaintBoundary isolates painting operations but doesn't affect the underlying animation timing or widget lifecycle issues that may be causing the flicker. The issue likely occurs at a deeper level within the CardSwiper's animation management or Flutter's rendering pipeline.
### Attempt 6: Delayed setState After Swipe Animation (FAILED - MADE WORSE)
**Theory**: The flicker was caused by immediate setState calls in the onSwipe callback interrupting the CardSwiper's animation. By delaying the state update until after the animation completes, the CardSwiper could finish its exit animation cleanly.
**Changes Made**:
```dart
// Before
onSwipe: (previousIndex, currentIndex, direction) {
final decision = direction == CardSwiperDirection.left
? SwipeDecision.delete
: SwipeDecision.keep;
// Handle the swipe decision
_handleSwipeDecision(decision);
return true;
},
// After
onSwipe: (previousIndex, currentIndex, direction) {
final decision = direction == CardSwiperDirection.left
? SwipeDecision.delete
: SwipeDecision.keep;
// Delay state update to allow animation to complete
Future.delayed(const Duration(milliseconds: 150), () {
_handleSwipeDecision(decision);
});
return true;
},
```
**Result**: Made the issue worse - introduced additional visual lag and the flicker remained.
**Why it failed**: The 150ms delay created a noticeable gap where no visual feedback occurred after the swipe, making the interface feel unresponsive. The flicker still occurred when the delayed setState finally triggered. This approach fundamentally misunderstood that the flicker happens during the transition between cards, not necessarily from immediate state updates.
### Attempt 7: IsolatedCardSwiper + ValueNotifiers to Eliminate All setState (FAILED)
**Theory**: The flicker was caused by any setState calls in the parent widget tree, even with the IsolatedCardSwiper. By replacing all setState calls during swipe actions with ValueNotifiers and ValueListenableBuilder, we could achieve true isolation where no parent widgets rebuild during swipe animations.
**Changes Made**:
```dart
// Step 1: Created IsolatedCardSwiper widget
class IsolatedCardSwiper extends StatefulWidget {
// Separate widget containing CardSwiper with stable configuration
// Uses callbacks to notify parent without triggering rebuilds
}
// Step 2: Added ValueNotifiers in parent
late ValueNotifier<int> _currentImageIndexNotifier;
late ValueNotifier<Map<EnteFile, SwipeDecision>> _decisionsNotifier;
// Step 3: Modified callbacks to use ValueNotifiers instead of setState
void _handleSwipeDecision(EnteFile file, SwipeDecision decision) {
// Update data without setState
decisions[file] = decision;
// ... history updates ...
// Only trigger ValueNotifier update
_decisionsNotifier.value = Map.from(decisions);
}
void _handleCurrentIndexChanged(int currentIndex) {
currentImageIndex = currentIndex;
_currentImageIndexNotifier.value = currentIndex;
}
// Step 4: Wrapped UI elements with ValueListenableBuilder
ValueListenableBuilder<int>(
valueListenable: _currentImageIndexNotifier,
builder: (context, currentIndex, child) {
return ValueListenableBuilder<Map<EnteFile, SwipeDecision>>(
valueListenable: _decisionsNotifier,
builder: (context, decisionsMap, child) {
return _buildProgressDots(theme);
},
);
},
)
```
**Components Wrapped**:
- Progress dots (listen to index + decisions)
- File info display (listen to index)
- Header delete button (listen to decisions)
- Action buttons (listen to index)
**Result**: No improvement - flicker still present exactly as before.
**Why it failed**: Despite eliminating all setState calls during swipe actions and isolating the CardSwiper in a separate widget, the flicker persisted. This suggests the issue is deeper within the flutter_card_swiper package itself, possibly in its internal animation handling or rendering pipeline. The approach was sound in theory but couldn't address what appears to be a fundamental limitation in the CardSwiper's animation system.
**Additional Insight**: This comprehensive approach combining widget isolation + ValueNotifiers represents the most thorough attempt to eliminate external interference with CardSwiper animations. Its failure strongly indicates the flicker is an intrinsic issue with the package rather than our state management.
### Attempt 8: Defer onSwipe State Update to Next Frame (FAILED - MADE WORSE)
**Theory**: The flicker is caused by a rebuild that lands during the CardSwiper's exit/settle phase. Scheduling the state update to the next frame (instead of a fixed delay) should let the current animation frame complete without interruption.
**Changes Made**:
```dart
// Added import
import 'package:flutter/scheduler.dart';
// Before
onSwipe: (previousIndex, currentIndex, direction) {
final decision = direction == CardSwiperDirection.left
? SwipeDecision.delete
: SwipeDecision.keep;
_handleSwipeDecision(decision);
return true;
},
// After (schedule next-frame update instead of immediate setState)
onSwipe: (previousIndex, currentIndex, direction) {
final decision = direction == CardSwiperDirection.left
? SwipeDecision.delete
: SwipeDecision.keep;
SchedulerBinding.instance.addPostFrameCallback((_) {
if (mounted) {
_handleSwipeDecision(decision);
}
});
return true;
},
```
**Result**: Made the flicker more pronounced/longer. Change reverted.
**Why it failed**: CardSwiper appears to invoke `onSwipe` while its internal index/stack is mid-transition. Deferring by one frame still triggers a parent rebuild exactly as CardSwiper completes its settle, so the visual discontinuity remains (and can become more visible). This suggests the root cause is not purely the timing of our setState, but the package's internal sequence of index change and card recycling.
### Attempt 9: Return Nothing for Out-of-Range Card Indices (FAILED)
**Theory**: The flicker might be a single-frame flash from our fallback card drawing. When `cardBuilder` is asked for an index that maps beyond `currentGroupFiles.length`, rendering a decorated placeholder Container paints a background for one frame. Returning a non-painting widget should eliminate the flash.
**Changes Made**:
```dart
// Before
if (fileIndex >= currentGroupFiles.length) {
return Container(
decoration: BoxDecoration(
color: theme.backgroundBase,
borderRadius: BorderRadius.circular(16),
),
);
}
// After
if (fileIndex >= currentGroupFiles.length) {
return const SizedBox.shrink(); // do not paint anything
}
```
**Result**: No improvement; flicker persisted. Change reverted.
**Why it failed**: Even when nothing is painted for transient indices, CardSwiper's internal rearrangement of the stack (combined with our dynamic `cardsCount` and `currentImageIndex` mapping) still produces a visible discontinuity as the top card is replaced and the next card is promoted. The issue likely stems from how the package recycles widgets/animations during the final settle rather than from our fallback rendering.
## Current Status
Nine attempts have been reverted. The flicker issue persists and appears to be an inherent limitation of the flutter_card_swiper package itself.
## Potential Next Steps for Investigation
1. **Examine flutter_card_swiper internals**: The issue might be within the CardSwiper package itself
2. **Check image loading/caching**: The flicker might be related to image transitions between cards
3. **Animation timing**: Look at the coordination between swipe animation completion and next card display
4. **Widget tree analysis**: Use Flutter Inspector to see exactly what's rebuilding during the flicker
5. **Alternative swipe packages**: Consider if flutter_card_swiper has known issues with this behavior
## Code Location
The main swipe implementation is in:
- `/lib/ui/pages/library_culling/swipe_culling_page.dart` (lines ~615-690 for CardSwiper widget)
- `/lib/ui/pages/library_culling/widgets/swipeable_photo_card.dart` (individual card implementation)
## Test Scenario
To reproduce the flicker:
1. Open swipe culling interface
2. Swipe any image (left or right)
3. Observe the brief flicker at the end of the swipe animation as the next card settles into place

Binary file not shown.

Before

Width:  |  Height:  |  Size: 196 KiB

View File

@@ -1,134 +0,0 @@
# Photo Swipe Culling - Implementation Progress
## Current Status: ✅ FEATURE COMPLETE
The photo swipe culling feature has been fully implemented with all planned functionality and UI refinements. The feature is ready for production use.
## Completed Implementation
### Phase 1: Core Features ✅
- [x] Create directory structure: `lib/ui/pages/library_culling/`
- [x] Install flutter_card_swiper package (^7.0.1)
- [x] Main swipe card interface with smooth animations
- [x] Group carousel for multi-group navigation
- [x] Progress tracking with auto-advance between groups
- [x] Undo functionality within groups
- [x] Group summary popup with grid view
- [x] Deletion logic with symlink preservation
- [x] Localization for all UI strings
- [x] Entry point from Similar Images page
### Phase 2: Initial UI Improvements ✅
- [x] Fix carousel icon visibility - check filtered groups
- [x] Fix swipe overlay - colored borders instead of full overlay
- [x] Fix black screen bug (unique keys, controller reset)
- [x] Show full uncropped images with proper quality
- [x] Redesign group carousel with stacked thumbnails
- [x] Fix tap behavior (tap current group shows summary)
- [x] Speed up completion animation (250ms)
- [x] Replace "X of Y" with Instagram-style progress dots
- [x] Separate containers for action buttons
### Phase 3: Final UI Refinements ✅
- [x] Square thumbnails (72x72px) with proper spacing
- [x] Visible 1px borders on stacked thumbnails
- [x] Remove "Best" label (postponed to v2)
- [x] Separate containers for like/dislike, no container for undo
- [x] Change heart icon to thumb_up_outlined
- [x] Thin swipe borders (4px max)
- [x] Progress dots above image (better visibility)
- [x] Vertical button layout in group summary
- [x] File info (name & size) directly below image
- [x] Red delete button with trash icon in header
- [x] Large square bottom buttons (72x72px)
- [x] Badges on corner edges (overlapping boundaries)
- [x] Ente-style confirmation dialogs with "Confirm" button
- [x] Muted color for undo button
## Technical Implementation
### Architecture
- **State Management**: StatefulWidget with setState
- **Package Used**: flutter_card_swiper ^7.0.1
- **Deletion**: Reuses existing `_deleteFilesLogic` with symlinks
- **Filtering**: Excludes single-image and 50+ image groups
- **Design System**: Follows Ente color scheme and patterns
### File Structure
```
lib/ui/pages/library_culling/
├── swipe_culling_page.dart # Main page (~850 lines)
├── models/
│ └── swipe_culling_state.dart # Data models
└── widgets/
├── swipeable_photo_card.dart # Card with border feedback
├── group_carousel.dart # Square thumbnails, badges
└── group_summary_popup.dart # Grid view, vertical buttons
```
### Key Features
- **Swipe Gestures**: Right = Keep, Left = Delete
- **Visual Feedback**: Colored borders that intensify with swipe distance
- **Group Navigation**: Tap to switch, long-press for summary
- **Progress Tracking**: Dots show decisions (red/green/gray)
- **Batch Processing**: Review all decisions before final deletion
- **Safety**: Symlinks preserve album associations
## Quality Assurance
- ✅ Flutter analyze: 0 issues in photos app
- ✅ All imports properly ordered
- ✅ No deprecated APIs used
- ✅ Proper null safety
- ✅ Consistent code style
- ✅ Localization complete
## Remaining improvements/fixes
- [x] Use circular undo icon as specified in feature plan
- [x] Double pressing the image in card should zoom in to image by pushing the `DetailPage` with hero animation (check `similar_images_page.dart` for example).
- [x] Stack next image behind current image with darkening/opacity, peeking from top. Shows full image preview that animates forward when current is swiped.
- [x] Fix issue with the carousel groups looking too dark. Even the selected group in carousel row looks darker than the current image, which is weird.
- [x] Pressing the undo button when nothing is decided in current group should navigate the user to the last group with changes and undo a change there.
- [x] Make the undo button animate nicely to the previous photo, instead of this flicker. Implemented with AnimatedSwitcher for smooth fade/slide transitions.
- [x] Show current image with ALL next ones stacked behind it, instead of only the next one stacked behind it. Make them stick out at the top. Make sure the transition after swiping is smooth, with no weird flickers.
- [ ] Get rid of the flicker that happens after swiping at the end of the animation. (See `flicker_fix_attempts.md` for documented failed approaches)
- [ ] Move the file info (name, size) higher up, to be just below the image. Make sure it's not part of the swipable card though.
- [ ] Animate going from last image in group to first image in next group
- [ ] Better placement of the instagram-like progress dots
- [ ] Bug: when only having a single group, finishing it, and then canceling the delete, the complete checkmark animation stays on screen. Which is fine, but it doesn't disappear on pressing the undo button.
## Remaining Tasks (Optional)
- [ ] Production testing with various group sizes
- [ ] Performance monitoring with large datasets
- [ ] User feedback collection
- [ ] A/B testing for UX improvements
## Future Enhancements (v2)
- [ ] AI-powered "Best Picture" detection
- [ ] Decision persistence across sessions
- [ ] Batch operations ("Delete all except first")
- [ ] Advanced statistics dashboard
- [ ] Customizable swipe sensitivity
- [ ] Cloud backup of decisions
- [ ] Machine learning for quality detection
## Notes
- **Priority**: Smooth 60fps animations maintained
- **Security**: No analytics or tracking code
- **Privacy**: All processing done locally
- **E2E Encryption**: Fully preserved
- **Design**: Follows Ente design language throughout
---
_Last Updated: Current session - All features implemented and tested_

Binary file not shown.

Before

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

@@ -1906,72 +1906,6 @@
},
"deleteFiles": "Delete files",
"areYouSureDeleteFiles": "Are you sure you want to delete these files?",
"swipeToReview": "Swipe to Review",
"imageXOfY": "{current} of {total}",
"@imageXOfY": {
"placeholders": {
"current": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"allGroupsReviewed": "All groups reviewed!",
"filesMarkedForDeletion": "{count} files marked for deletion",
"@filesMarkedForDeletion": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"storageToBeFreed": "{size} will be freed",
"@storageToBeFreed": {
"placeholders": {
"size": {
"type": "String"
}
}
},
"deletePhotosBody": "Delete {count} photos? This will free up {size}",
"@deletePhotosBody": {
"placeholders": {
"count": {
"type": "String"
},
"size": {
"type": "String"
}
}
},
"deletedPhotosWithSize": "Deleted {count} photos and freed {size}",
"@deletedPhotosWithSize": {
"placeholders": {
"count": {
"type": "String"
},
"size": {
"type": "String"
}
}
},
"reviewAgain": "Review Again",
"deleteThese": "Delete These",
"undoAll": "Undo All",
"groupComplete": "Group complete",
"deleteAllInGroup": "Delete all images in this group?",
"allImagesMarkedForDeletion": "You've marked all {count} images for deletion",
"@allImagesMarkedForDeletion": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"congratulations": "Congratulations!",
"noImagesSelected": "No images selected",
"greatJob": "Great job!",
"cleanedUpSimilarImages": "You freed up {size} of space",
"@cleanedUpSimilarImages": {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,32 +0,0 @@
import 'package:photos/models/file/file.dart';
enum SwipeDecision { keep, delete, undecided }
class SwipeAction {
final EnteFile file;
final SwipeDecision decision;
final DateTime timestamp;
final int groupIndex;
SwipeAction({
required this.file,
required this.decision,
required this.timestamp,
required this.groupIndex,
});
}
class GroupProgress {
final int totalImages;
final int reviewedImages;
final int deletionCount;
GroupProgress({
required this.totalImages,
required this.reviewedImages,
required this.deletionCount,
});
bool get isComplete => reviewedImages == totalImages;
double get progressPercentage => reviewedImages / totalImages;
}

View File

@@ -1,971 +0,0 @@
import 'dart:async' show Future, unawaited;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:flutter_card_swiper/flutter_card_swiper.dart';
import 'package:logging/logging.dart';
import 'package:photos/core/configuration.dart';
import 'package:photos/generated/l10n.dart';
import 'package:photos/models/file/file.dart';
import 'package:photos/models/similar_files.dart';
import 'package:photos/services/collections_service.dart';
import 'package:photos/theme/ente_theme.dart';
import 'package:photos/ui/pages/library_culling/models/swipe_culling_state.dart';
import 'package:photos/ui/pages/library_culling/widgets/group_carousel.dart';
import 'package:photos/ui/pages/library_culling/widgets/group_summary_popup.dart';
import 'package:photos/ui/pages/library_culling/widgets/swipeable_photo_card.dart';
import 'package:photos/utils/delete_file_util.dart';
import 'package:photos/utils/dialog_util.dart';
import 'package:photos/utils/standalone/data.dart';
class SwipeCullingPage extends StatefulWidget {
final List<SimilarFiles> similarFiles;
const SwipeCullingPage({
super.key,
required this.similarFiles,
});
@override
State<SwipeCullingPage> createState() => _SwipeCullingPageState();
}
class _SwipeCullingPageState extends State<SwipeCullingPage>
with TickerProviderStateMixin {
final _logger = Logger("SwipeCullingPage");
late List<SimilarFiles> groups;
int currentGroupIndex = 0;
int currentImageIndex = 0;
Map<EnteFile, SwipeDecision> decisions = {};
Map<int, List<SwipeAction>> groupHistories = {};
List<SwipeAction> fullHistory = [];
late CardSwiperController controller;
late ValueNotifier<String> _deleteProgress;
// Animation controllers for celebrations
late AnimationController _celebrationController;
late AnimationController _progressRingController;
bool _showingCelebration = false;
@override
void initState() {
super.initState();
controller = CardSwiperController();
_deleteProgress = ValueNotifier("");
_celebrationController = AnimationController(
duration: const Duration(milliseconds: 400),
vsync: this,
);
_progressRingController = AnimationController(
duration: const Duration(seconds: 2),
vsync: this,
);
_initializeGroups();
}
@override
void dispose() {
_deleteProgress.dispose();
_celebrationController.dispose();
_progressRingController.dispose();
controller.dispose();
super.dispose();
}
void _initializeGroups() {
// Filter groups (no singles, no 50+)
groups = widget.similarFiles
.where((g) => g.files.length > 1 && g.files.length < 50)
.toList();
// Initialize all as undecided
for (final group in groups) {
for (final file in group.files) {
decisions[file] = SwipeDecision.undecided;
}
groupHistories[groups.indexOf(group)] = [];
}
}
List<EnteFile> get currentGroupFiles {
if (groups.isEmpty || currentGroupIndex >= groups.length) {
return [];
}
return groups[currentGroupIndex].files;
}
EnteFile? get currentFile {
final files = currentGroupFiles;
if (files.isEmpty || currentImageIndex >= files.length) {
return null;
}
return files[currentImageIndex];
}
int get totalDeletionCount {
return decisions.values.where((d) => d == SwipeDecision.delete).length;
}
GroupProgress getGroupProgress(int groupIndex) {
if (groupIndex >= groups.length) {
return GroupProgress(totalImages: 0, reviewedImages: 0, deletionCount: 0);
}
final group = groups[groupIndex];
int reviewed = 0;
int toDelete = 0;
for (final file in group.files) {
final decision = decisions[file] ?? SwipeDecision.undecided;
if (decision != SwipeDecision.undecided) {
reviewed++;
if (decision == SwipeDecision.delete) {
toDelete++;
}
}
}
return GroupProgress(
totalImages: group.files.length,
reviewedImages: reviewed,
deletionCount: toDelete,
);
}
void _handleSwipeDecision(SwipeDecision decision) {
final file = currentFile;
if (file == null) return;
// Haptic feedback for swipe action
unawaited(HapticFeedback.lightImpact());
setState(() {
decisions[file] = decision;
final action = SwipeAction(
file: file,
decision: decision,
timestamp: DateTime.now(),
groupIndex: currentGroupIndex,
);
groupHistories[currentGroupIndex]?.add(action);
fullHistory.add(action);
// Move to next image
if (currentImageIndex < currentGroupFiles.length - 1) {
currentImageIndex++;
} else {
// Group complete - check if all images marked for deletion
final groupProgress = getGroupProgress(currentGroupIndex);
if (groupProgress.deletionCount == groupProgress.totalImages &&
groupProgress.totalImages > 0) {
_showAllInGroupDeletionDialog();
} else {
_handleGroupCompletion();
}
}
});
}
void _handleGroupCompletion() async {
if (_showingCelebration) return;
// Haptic feedback
unawaited(HapticFeedback.mediumImpact());
setState(() {
_showingCelebration = true;
});
// Ultra-quick celebration animation
_celebrationController.duration = const Duration(milliseconds: 250);
unawaited(_celebrationController.forward());
await Future.delayed(const Duration(milliseconds: 250));
// Move to next group or show completion
if (currentGroupIndex < groups.length - 1) {
setState(() {
currentGroupIndex++;
currentImageIndex = 0;
_showingCelebration = false;
});
_celebrationController.reset();
_progressRingController.reset();
} else {
_showCompletionDialog();
}
}
void _showAllInGroupDeletionDialog() {
final groupSize = currentGroupFiles.length;
showChoiceDialog(
context,
title: AppLocalizations.of(context).deleteAllInGroup,
body: AppLocalizations.of(context)
.allImagesMarkedForDeletion(count: groupSize),
firstButtonLabel: AppLocalizations.of(context).confirm,
secondButtonLabel: AppLocalizations.of(context).reviewAgain,
isCritical: true,
firstButtonOnTap: () async {
_handleGroupCompletion();
},
secondButtonOnTap: () async {
// Review again - reset this group's decisions
setState(() {
for (final file in currentGroupFiles) {
decisions[file] = SwipeDecision.undecided;
}
currentImageIndex = 0;
groupHistories[currentGroupIndex]?.clear();
});
},
);
}
void _showCompletionDialog() {
final filesToDelete = <EnteFile>{};
int totalSize = 0;
for (final entry in decisions.entries) {
if (entry.value == SwipeDecision.delete) {
filesToDelete.add(entry.key);
totalSize += entry.key.fileSize ?? 0;
}
}
if (filesToDelete.isEmpty) {
Navigator.of(context).pop(0);
return;
}
showChoiceDialog(
context,
title: AppLocalizations.of(context).deletePhotos,
body: AppLocalizations.of(context).deletePhotosBody(
count: filesToDelete.length.toString(),
size: formatBytes(totalSize),
),
firstButtonLabel: AppLocalizations.of(context).delete,
isCritical: true,
firstButtonOnTap: () async {
try {
await _deleteFilesLogic(filesToDelete, true);
if (mounted) {
Navigator.of(context).pop(filesToDelete.length);
}
} catch (e, s) {
_logger.severe("Failed to delete files", e, s);
if (mounted) {
await showGenericErrorDialog(context: context, error: e);
}
}
},
);
}
void _handleUndo() {
// Check if current group has history
if (groupHistories[currentGroupIndex]?.isNotEmpty ?? false) {
// Undo in current group
setState(() {
final lastAction = groupHistories[currentGroupIndex]!.removeLast();
fullHistory.removeLast();
decisions[lastAction.file] = SwipeDecision.undecided;
// Move back to the undone image
final fileIndex = currentGroupFiles.indexOf(lastAction.file);
if (fileIndex != -1) {
currentImageIndex = fileIndex;
}
});
} else {
// Find the last group with changes to undo
int? targetGroupIndex;
SwipeAction? lastAction;
for (int i = groups.length - 1; i >= 0; i--) {
if (groupHistories[i]?.isNotEmpty ?? false) {
targetGroupIndex = i;
lastAction = groupHistories[i]!.last;
break;
}
}
if (targetGroupIndex != null && targetGroupIndex != currentGroupIndex) {
// Switch to the group with history and undo the last action there
setState(() {
currentGroupIndex = targetGroupIndex!;
// Remove the last action from that group
groupHistories[targetGroupIndex]!.removeLast();
fullHistory.removeLast();
decisions[lastAction!.file] = SwipeDecision.undecided;
// Find the index of the undone file in the target group
final fileIndex =
groups[targetGroupIndex].files.indexOf(lastAction.file);
if (fileIndex != -1) {
currentImageIndex = fileIndex;
} else {
currentImageIndex = 0;
}
});
}
}
}
Map<int, GroupProgress> get progressMap {
final map = <int, GroupProgress>{};
for (int i = 0; i < groups.length; i++) {
map[i] = getGroupProgress(i);
}
return map;
}
void _switchToGroup(int groupIndex) {
if (groupIndex < 0 || groupIndex >= groups.length) return;
setState(() {
currentGroupIndex = groupIndex;
currentImageIndex = 0;
// Find first undecided image in the group
final files = groups[groupIndex].files;
for (int i = 0; i < files.length; i++) {
if (decisions[files[i]] == SwipeDecision.undecided) {
currentImageIndex = i;
break;
}
}
});
}
void _showGroupSummaryPopup(int groupIndex) {
if (groupIndex < 0 || groupIndex >= groups.length) return;
final group = groups[groupIndex];
showDialog(
context: context,
builder: (context) => GroupSummaryPopup(
group: group,
decisions: decisions,
onUndoAll: () {
setState(() {
// Reset all decisions for this group
for (final file in group.files) {
decisions[file] = SwipeDecision.undecided;
}
// Clear group history
groupHistories[groupIndex]?.clear();
// Remove from full history
fullHistory
.removeWhere((action) => action.groupIndex == groupIndex);
});
Navigator.of(context).pop();
_switchToGroup(groupIndex);
},
onDeleteThese: () async {
// Get files to delete from this group
final filesToDelete = <EnteFile>{};
for (final file in group.files) {
if (decisions[file] == SwipeDecision.delete) {
filesToDelete.add(file);
}
}
if (filesToDelete.isNotEmpty) {
Navigator.of(context).pop();
await _deleteFilesLogic(filesToDelete, true);
// Remove this group from the list if all deleted
if (filesToDelete.length == group.files.length) {
setState(() {
groups.removeAt(groupIndex);
if (currentGroupIndex >= groups.length && groups.isNotEmpty) {
currentGroupIndex = groups.length - 1;
currentImageIndex = 0;
}
});
}
}
},
),
);
}
Widget _buildProgressDots(theme) {
final totalImages = currentGroupFiles.length;
if (totalImages == 0) return const SizedBox.shrink();
// Limit dots to max 10 for readability
const maxDots = 10;
final showAllDots = totalImages <= maxDots;
return SizedBox(
height: 8,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: List.generate(
showAllDots ? totalImages : maxDots,
(index) {
// For collapsed view, calculate which image this dot represents
final imageIndex =
showAllDots ? index : (index * totalImages / maxDots).floor();
final decision = decisions[currentGroupFiles[imageIndex]];
final isCurrent = showAllDots
? index == currentImageIndex
: imageIndex <= currentImageIndex &&
(index == maxDots - 1 ||
((index + 1) * totalImages / maxDots).floor() >
currentImageIndex);
Color dotColor;
double dotSize = 6;
if (decision == SwipeDecision.delete) {
dotColor = theme.warning700;
} else if (decision == SwipeDecision.keep) {
dotColor = theme.primary500;
} else if (isCurrent) {
dotColor = theme.textBase;
dotSize = 8;
} else {
dotColor = theme.strokeFaint;
}
return Container(
margin: const EdgeInsets.symmetric(horizontal: 2),
width: dotSize,
height: dotSize,
decoration: BoxDecoration(
color: dotColor,
shape: BoxShape.circle,
),
);
},
),
),
);
}
Future<void> _deleteFilesLogic(
Set<EnteFile> filesToDelete,
bool createSymlink,
) async {
if (filesToDelete.isEmpty) {
return;
}
final Map<int, Set<EnteFile>> collectionToFilesToAddMap = {};
final allDeleteFiles = <EnteFile>{};
for (final group in groups) {
final groupDeleteFiles = <EnteFile>{};
for (final file in filesToDelete) {
if (group.containsFile(file)) {
groupDeleteFiles.add(file);
allDeleteFiles.add(file);
}
}
if (groupDeleteFiles.isNotEmpty && createSymlink) {
final filesToKeep =
group.files.where((f) => !groupDeleteFiles.contains(f)).toSet();
final collectionIDs =
filesToKeep.map((file) => file.collectionID).toSet();
for (final deletedFile in groupDeleteFiles) {
final collectionID = deletedFile.collectionID;
if (collectionIDs.contains(collectionID) || collectionID == null) {
continue;
}
if (!collectionToFilesToAddMap.containsKey(collectionID)) {
collectionToFilesToAddMap[collectionID] = {};
}
collectionToFilesToAddMap[collectionID]!.addAll(filesToKeep);
}
}
}
final int collectionCnt = collectionToFilesToAddMap.keys.length;
if (createSymlink) {
final userID = Configuration.instance.getUserID();
int progress = 0;
for (final collectionID in collectionToFilesToAddMap.keys) {
if (!mounted) {
return;
}
if (collectionCnt > 2) {
progress++;
final double percentage = (progress / collectionCnt) * 100;
_deleteProgress.value = '${percentage.toStringAsFixed(1)}%';
}
// Check permission before attempting to add symlinks
final collection =
CollectionsService.instance.getCollectionByID(collectionID);
if (collection != null && collection.canAutoAdd(userID!)) {
await CollectionsService.instance.addSilentlyToCollection(
collectionID,
collectionToFilesToAddMap[collectionID]!.toList(),
);
} else {
_logger.warning(
"Skipping adding symlinks to collection $collectionID due to missing permissions",
);
}
}
}
if (collectionCnt > 2) {
_deleteProgress.value = "";
}
await deleteFilesFromRemoteOnly(context, allDeleteFiles.toList());
// Show congratulations if more than 100 files deleted
if (allDeleteFiles.length > 100 && mounted) {
final int totalSize = allDeleteFiles.fold<int>(
0,
(sum, file) => sum + (file.fileSize ?? 0),
);
_showCongratulationsDialog(allDeleteFiles.length, totalSize);
}
}
void _showCongratulationsDialog(int deletedCount, int totalSize) {
showDialog(
context: context,
builder: (context) {
return AlertDialog(
title: Text(AppLocalizations.of(context).congratulations),
content: Text(
AppLocalizations.of(context).deletedPhotosWithSize(
count: deletedCount.toString(),
size: formatBytes(totalSize),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(AppLocalizations.of(context).ok),
),
],
);
},
);
}
@override
Widget build(BuildContext context) {
final theme = getEnteColorScheme(context);
if (groups.isEmpty) {
return Scaffold(
appBar: AppBar(
title: Text(AppLocalizations.of(context).review),
),
body: Center(
child: Text(AppLocalizations.of(context).noImagesSelected),
),
);
}
return Stack(
children: [
Scaffold(
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () {
if (totalDeletionCount > 0) {
// TODO: Show exit confirmation if there are pending deletions
}
Navigator.of(context).pop();
},
),
title: const Text(''), // Empty title
actions: [
if (totalDeletionCount > 0)
Padding(
padding: const EdgeInsets.only(right: 8),
child: GestureDetector(
onTap: _showCompletionDialog,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 8,
),
decoration: BoxDecoration(
color: theme.warning500.withAlpha((0.1 * 255).toInt()),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.delete_outline,
size: 12,
color: theme.warning500,
),
const SizedBox(width: 4),
Text(
AppLocalizations.of(context)
.deleteWithCount(count: totalDeletionCount),
style: getEnteTextTheme(context).smallBold.copyWith(
color: theme.warning500,
),
),
],
),
),
),
),
],
),
body: Column(
children: [
// Group carousel at top
if (groups.length > 1)
Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: GroupCarousel(
groups: groups,
currentGroupIndex: currentGroupIndex,
onGroupSelected: _switchToGroup,
onGroupLongPress: _showGroupSummaryPopup,
progressMap: progressMap,
),
),
// Progress dots above image
if (currentFile != null)
Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: _buildProgressDots(theme),
),
Expanded(
child: currentFile != null
? Column(
children: [
Expanded(
child: Stack(
alignment: Alignment.center,
children: [
// CardSwiper without AnimatedSwitcher wrapper
CardSwiper(
key: ValueKey('swiper_${currentGroupIndex}_$currentImageIndex'),
controller: controller,
cardsCount: currentGroupFiles.length -
currentImageIndex,
numberOfCardsDisplayed:
// Show up to 4 cards stacked, or all remaining if less
(currentGroupFiles.length - currentImageIndex)
.clamp(1, 4),
backCardOffset: const Offset(
0,
-20,
), // Minimal peek from top for cleaner stacking
padding: const EdgeInsets.all(20.0),
cardBuilder: (
context,
index,
percentThresholdX,
percentThresholdY,
) {
final fileIndex = currentImageIndex + index;
if (fileIndex >= currentGroupFiles.length) {
// Return a placeholder container instead of SizedBox.shrink()
return Container(
decoration: BoxDecoration(
color: theme.backgroundBase,
borderRadius:
BorderRadius.circular(16),
),
);
}
final file = currentGroupFiles[fileIndex];
// Calculate swipe progress for overlay effects (only for front card)
final swipeProgress = index == 0
? percentThresholdX / 100
: 0.0;
final isSwipingLeft =
index == 0 && swipeProgress < -0.1;
final isSwipingRight =
index == 0 && swipeProgress > 0.1;
// Simple card without any custom wrapping
return SwipeablePhotoCard(
key: ValueKey(
file.uploadedFileID ?? file.localID,
),
file: file,
swipeProgress: swipeProgress,
isSwipingLeft: isSwipingLeft,
isSwipingRight: isSwipingRight,
showFileInfo:
false, // Never show file info in cards
);
},
onSwipe:
(previousIndex, currentIndex, direction) {
final decision =
direction == CardSwiperDirection.left
? SwipeDecision.delete
: SwipeDecision.keep;
// Handle the swipe decision
_handleSwipeDecision(decision);
return true;
},
onEnd: () {
// All cards in current group have been swiped
// This is handled in _handleSwipeDecision when reaching last card
},
isDisabled: false,
threshold: 50,
),
// Minimal celebration overlay
if (_showingCelebration)
Container(
color: Colors.black.withValues(alpha: 0.2),
child: Center(
child: Icon(
Icons.check_circle,
size: 48,
color: theme.primary500,
)
.animate(
controller: _celebrationController,
)
.scaleXY(
begin: 0.5,
end: 1.2,
curve: Curves.easeOut,
)
.fadeIn(
duration: 100.ms,
),
),
),
],
),
),
],
)
: Center(
child:
Text(AppLocalizations.of(context).noImagesSelected),
),
),
// File info display (below cards, above action buttons)
if (currentFile != null)
Padding(
padding:
const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
child: Column(
children: [
Text(
currentFile!.displayName,
style: getEnteTextTheme(context).body,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
),
const SizedBox(height: 4),
Text(
formatBytes(currentFile!.fileSize ?? 0),
style: getEnteTextTheme(context).small.copyWith(
color: theme.textMuted,
),
textAlign: TextAlign.center,
),
],
),
),
// Action buttons at bottom
Padding(
padding: const EdgeInsets.only(
left: 32,
right: 32,
bottom: 48,
top: 8,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
// Delete button - 72x72
Container(
width: 72,
height: 72,
decoration: BoxDecoration(
color: currentFile != null
? theme.backgroundElevated2
: theme.backgroundBase,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: currentFile != null
? theme.strokeFaint
: theme.strokeFainter,
width: 1,
),
boxShadow: currentFile != null
? [
BoxShadow(
color: Colors.black.withValues(alpha: 0.08),
blurRadius: 8,
offset: const Offset(0, 2),
),
]
: null,
),
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: currentFile != null
? () {
HapticFeedback.lightImpact();
controller.swipe(CardSwiperDirection.left);
}
: null,
child: Center(
child: Icon(
Icons.close,
color: currentFile != null
? theme.warning700
: theme.strokeFainter,
size: 32,
),
),
),
),
),
// Undo button without container
IconButton(
onPressed: _handleUndo,
icon: Icon(
Icons.replay,
color: theme.textMuted.withValues(alpha: 0.6),
size: 28,
),
padding: const EdgeInsets.all(12),
splashRadius: 28,
),
// Keep button - 72x72
Container(
width: 72,
height: 72,
decoration: BoxDecoration(
color: currentFile != null
? theme.backgroundElevated2
: theme.backgroundBase,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: currentFile != null
? theme.strokeFaint
: theme.strokeFainter,
width: 1,
),
boxShadow: currentFile != null
? [
BoxShadow(
color: Colors.black.withValues(alpha: 0.08),
blurRadius: 8,
offset: const Offset(0, 2),
),
]
: null,
),
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: currentFile != null
? () {
HapticFeedback.lightImpact();
controller.swipe(CardSwiperDirection.right);
}
: null,
child: Center(
child: Icon(
Icons.thumb_up_outlined,
color: currentFile != null
? theme.primary700
: theme.strokeFainter,
size: 32,
),
),
),
),
),
],
),
),
],
),
),
// Progress overlay during deletion
ValueListenableBuilder(
valueListenable: _deleteProgress,
builder: (context, value, child) {
if (value.isEmpty) {
return const SizedBox.shrink();
}
return Container(
color: theme.backgroundBase.withValues(alpha: 0.8),
child: Center(
child: Container(
padding:
const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
decoration: BoxDecoration(
color: theme.backgroundElevated,
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: theme.strokeFaint,
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation(theme.primary500),
),
),
const SizedBox(width: 12),
Text(
'Deleting... $value',
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
),
);
},
),
],
);
}
}

View File

@@ -1,287 +0,0 @@
import 'package:flutter/material.dart';
import 'package:photos/models/similar_files.dart';
import 'package:photos/theme/ente_theme.dart';
import 'package:photos/ui/pages/library_culling/models/swipe_culling_state.dart';
import 'package:photos/ui/viewer/file/thumbnail_widget.dart';
class GroupCarousel extends StatefulWidget {
final List<SimilarFiles> groups;
final int currentGroupIndex;
final Function(int) onGroupSelected;
final Function(int) onGroupLongPress;
final Map<int, GroupProgress> progressMap;
const GroupCarousel({
super.key,
required this.groups,
required this.currentGroupIndex,
required this.onGroupSelected,
required this.onGroupLongPress,
required this.progressMap,
});
@override
State<GroupCarousel> createState() => _GroupCarouselState();
}
class _GroupCarouselState extends State<GroupCarousel> {
late ScrollController _scrollController;
@override
void initState() {
super.initState();
_scrollController = ScrollController();
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
@override
void didUpdateWidget(GroupCarousel oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.currentGroupIndex != oldWidget.currentGroupIndex) {
_scrollToCurrentGroup();
}
}
void _scrollToCurrentGroup() {
if (!_scrollController.hasClients) return;
// Calculate the position to scroll to (72 width + 16 padding per item)
const itemWidth = 72.0 + 16.0;
final targetPosition = widget.currentGroupIndex * itemWidth;
// Center the current group in the viewport if possible
final viewportWidth = _scrollController.position.viewportDimension;
final maxScrollExtent = _scrollController.position.maxScrollExtent;
final centeredPosition = targetPosition - (viewportWidth / 2) + (itemWidth / 2);
final scrollPosition = centeredPosition.clamp(0.0, maxScrollExtent);
_scrollController.animateTo(
scrollPosition,
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
}
@override
Widget build(BuildContext context) {
final theme = getEnteColorScheme(context);
// Scroll to current group after build
WidgetsBinding.instance.addPostFrameCallback((_) {
if (widget.currentGroupIndex > 0) {
_scrollToCurrentGroup();
}
});
return SizedBox(
height: 100,
child: ListView.builder(
controller: _scrollController,
clipBehavior: Clip.none,
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 5),
itemCount: widget.groups.length,
itemBuilder: (context, index) {
final group = widget.groups[index];
final progress = widget.progressMap[index] ??
GroupProgress(
totalImages: group.files.length,
reviewedImages: 0,
deletionCount: 0,
);
final isCurrentGroup = index == widget.currentGroupIndex;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: GestureDetector(
onTap: () => isCurrentGroup
? widget.onGroupLongPress(
index,
) // Show summary if tapping current group
: widget.onGroupSelected(index),
onLongPress: () => widget.onGroupLongPress(index),
child: SizedBox(
width: 72, // 72x90 rectangular
height: 90,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 200),
opacity: isCurrentGroup ? 1.0 : 0.6,
child: Stack(
clipBehavior: Clip.none,
children: [
// Build stacked thumbnails for current group, single for others
if (isCurrentGroup)
_buildStackedThumbnails(group, theme)
else
_buildSingleThumbnail(group),
// Progress/status badges
// Show red badge with deletion count if any images are marked for deletion
if (progress.deletionCount > 0)
Positioned(
top: -8,
right: -8,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: theme.warning700,
borderRadius: BorderRadius.circular(10),
),
child: Text(
'${progress.deletionCount}',
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
),
),
// Show green checkmark only if group is complete with no deletions
if (progress.isComplete && progress.deletionCount == 0)
Positioned(
top: -8,
right: -8,
child: Container(
padding: const EdgeInsets.all(2),
decoration: BoxDecoration(
color: theme.primary500,
shape: BoxShape.circle,
),
child: const Icon(
Icons.check,
size: 12,
color: Colors.white,
),
),
),
],
),
),
),
),
);
},
),
);
}
Widget _buildStackedThumbnails(SimilarFiles group, theme) {
if (group.files.isEmpty) {
return const SizedBox.shrink();
}
return Stack(
children: [
// Back card (rotated slightly)
if (group.files.length > 1)
Positioned(
top: 4,
left: 4,
right: 4,
bottom: 4,
child: Transform.rotate(
angle: -0.15, // More rotation for better separation
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: theme.strokeMuted,
width: 1.0,
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 2,
offset: const Offset(0, 1),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: ThumbnailWidget(
group.files[0],
fit: BoxFit.cover,
shouldShowLivePhotoOverlay: false,
shouldShowOwnerAvatar: false,
),
),
),
),
),
// Front card
Positioned(
top: 0,
left: 0,
right: 0,
bottom: 0,
child: Transform.rotate(
angle: 0.10, // More rotation opposite direction
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: theme.strokeMuted,
width: 1.0,
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.15),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: ThumbnailWidget(
group.files[0],
fit: BoxFit.cover,
shouldShowLivePhotoOverlay: false,
shouldShowOwnerAvatar: false,
),
),
),
),
),
],
);
}
Widget _buildSingleThumbnail(SimilarFiles group) {
if (group.files.isEmpty) {
return const SizedBox.shrink();
}
return Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: ThumbnailWidget(
group.files[0],
fit: BoxFit.cover,
shouldShowLivePhotoOverlay: false,
shouldShowOwnerAvatar: false,
),
),
);
}
}

View File

@@ -1,247 +0,0 @@
import 'package:flutter/material.dart';
import 'package:photos/generated/l10n.dart';
import 'package:photos/models/file/file.dart';
import 'package:photos/models/similar_files.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:photos/ui/pages/library_culling/models/swipe_culling_state.dart';
import 'package:photos/ui/viewer/file/detail_page.dart';
import 'package:photos/ui/viewer/file/thumbnail_widget.dart';
import 'package:photos/utils/navigation_util.dart';
import 'package:photos/utils/standalone/data.dart';
class GroupSummaryPopup extends StatelessWidget {
final SimilarFiles group;
final Map<EnteFile, SwipeDecision> decisions;
final VoidCallback onUndoAll;
final VoidCallback onDeleteThese;
const GroupSummaryPopup({
super.key,
required this.group,
required this.decisions,
required this.onUndoAll,
required this.onDeleteThese,
});
@override
Widget build(BuildContext context) {
final theme = getEnteColorScheme(context);
final textTheme = getEnteTextTheme(context);
final files = group.files;
// Calculate stats
int deletionCount = 0;
int decisionCount = 0;
int totalSize = 0;
for (final file in files) {
final decision = decisions[file] ?? SwipeDecision.undecided;
if (decision != SwipeDecision.undecided) {
decisionCount++;
}
if (decision == SwipeDecision.delete) {
deletionCount++;
totalSize += file.fileSize ?? 0;
}
}
return Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Container(
constraints: const BoxConstraints(
maxWidth: 400,
maxHeight: 600,
),
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Header with storage info
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Decisions',
style: textTheme.largeBold,
),
IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.close),
),
],
),
if (deletionCount > 0)
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(
AppLocalizations.of(context).storageToBeFreed(size: formatBytes(totalSize)),
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: theme.warning700,
),
),
),
// Grid of images with overlay indicators (no divider)
Expanded(
child: GridView.builder(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
crossAxisSpacing: 8,
mainAxisSpacing: 16, // More vertical spacing
childAspectRatio: 0.75, // Adjusted for square thumbnails with text
),
itemCount: files.length,
itemBuilder: (context, index) {
final file = files[index];
final decision = decisions[file] ?? SwipeDecision.undecided;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: GestureDetector(
onLongPress: () {
routeToPage(
context,
DetailPage(
DetailPageConfiguration(
files,
index,
"group_summary_",
mode: DetailPageMode.minimalistic,
),
),
);
},
child: AspectRatio(
aspectRatio: 1, // Square thumbnail
child: Stack(
fit: StackFit.expand,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Hero(
tag: "group_summary_${file.tag}",
child: ThumbnailWidget(
file,
fit: BoxFit.cover,
shouldShowLivePhotoOverlay: false,
shouldShowOwnerAvatar: false,
),
),
),
// Badge for deleted items (red trash icon)
if (decision == SwipeDecision.delete)
Positioned(
top: 4,
right: 4,
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: theme.warning700,
shape: BoxShape.circle,
),
child: const Icon(
Icons.delete_outline,
size: 16,
color: Colors.white,
),
),
),
// Checkmark for kept items
if (decision == SwipeDecision.keep)
Positioned(
top: 4,
right: 4,
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: theme.primary500,
shape: BoxShape.circle,
),
child: Icon(
Icons.check,
size: 16,
color: theme.backgroundBase,
),
),
),
// Badge for undecided (using icon for consistent size)
if (decision == SwipeDecision.undecided)
Positioned(
top: 4,
right: 4,
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.7),
shape: BoxShape.circle,
),
child: const Icon(
Icons.question_mark_outlined,
size: 16,
color: Colors.white,
),
),
),
],
),
),
),
),
const SizedBox(height: 6),
Text(
file.displayName,
style: textTheme.small,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.left,
),
const SizedBox(height: 2),
Text(
formatBytes(file.fileSize!),
style: textTheme.miniMuted,
textAlign: TextAlign.left,
),
],
);
},
),
),
const SizedBox(height: 16),
// Action buttons using Ente button design
if (deletionCount > 0) ...[
ButtonWidget(
buttonType: ButtonType.critical,
labelText: 'Confirm',
onTap: () async {
onDeleteThese();
},
isInAlert: true,
),
const SizedBox(height: 8),
],
if (decisionCount > 0)
ButtonWidget(
buttonType: ButtonType.secondary,
labelText: 'Undo decisions',
onTap: () async {
onUndoAll();
},
isInAlert: true,
),
],
),
),
);
}
}

View File

@@ -1,273 +0,0 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:photos/core/cache/thumbnail_in_memory_cache.dart';
import 'package:photos/core/constants.dart';
import 'package:photos/models/file/file.dart';
import 'package:photos/theme/ente_theme.dart';
import 'package:photos/ui/common/loading_widget.dart';
import 'package:photos/ui/viewer/file/detail_page.dart';
import 'package:photos/utils/file_util.dart';
import 'package:photos/utils/navigation_util.dart';
import 'package:photos/utils/standalone/data.dart';
import 'package:photos/utils/thumbnail_util.dart';
class SwipeablePhotoCard extends StatefulWidget {
final EnteFile file;
final double swipeProgress;
final bool isSwipingLeft;
final bool isSwipingRight;
final bool showFileInfo;
const SwipeablePhotoCard({
super.key,
required this.file,
this.swipeProgress = 0.0,
this.isSwipingLeft = false,
this.isSwipingRight = false,
this.showFileInfo = true,
});
@override
State<SwipeablePhotoCard> createState() => _SwipeablePhotoCardState();
}
class _SwipeablePhotoCardState extends State<SwipeablePhotoCard> {
ImageProvider? _imageProvider;
bool _loadingLargeThumbnail = false;
bool _loadedLargeThumbnail = false;
bool _loadingFinalImage = false;
bool _loadedFinalImage = false;
@override
void initState() {
super.initState();
_loadImage();
}
void _loadImage() {
// First load thumbnail from cache if available
final cachedThumbnail = ThumbnailInMemoryLruCache.get(widget.file, thumbnailSmallSize);
if (cachedThumbnail != null && mounted) {
setState(() {
_imageProvider = Image.memory(cachedThumbnail).image;
});
}
// Load large thumbnail
if (!_loadingLargeThumbnail && !_loadedLargeThumbnail && !_loadedFinalImage) {
_loadingLargeThumbnail = true;
if (widget.file.isRemoteFile) {
// For remote files, get thumbnail from server
getThumbnailFromServer(widget.file).then((file) {
if (mounted && !_loadedFinalImage) {
final imageProvider = Image.memory(file).image;
precacheImage(imageProvider, context).then((_) {
if (mounted && !_loadedFinalImage) {
setState(() {
_imageProvider = imageProvider;
_loadedLargeThumbnail = true;
});
}
});
}
});
} else {
// For local files, get large thumbnail
getThumbnailFromLocal(widget.file, size: thumbnailLargeSize, quality: 100)
.then((thumbnail) {
if (thumbnail != null && mounted && !_loadedFinalImage) {
final imageProvider = Image.memory(thumbnail).image;
precacheImage(imageProvider, context).then((_) {
if (mounted && !_loadedFinalImage) {
setState(() {
_imageProvider = imageProvider;
_loadedLargeThumbnail = true;
});
}
});
}
});
}
}
// Load final full-quality image
if (!_loadingFinalImage && !_loadedFinalImage) {
_loadingFinalImage = true;
if (widget.file.isRemoteFile) {
getFileFromServer(widget.file).then((file) {
if (file != null && mounted) {
_onFileLoaded(file);
}
});
} else {
getFile(widget.file).then((file) {
if (file != null && mounted) {
_onFileLoaded(file);
}
});
}
}
}
void _onFileLoaded(dynamic file) {
ImageProvider imageProvider;
if (file is Uint8List) {
imageProvider = Image.memory(file).image;
} else {
imageProvider = Image.file(file).image;
}
precacheImage(imageProvider, context).then((_) {
if (mounted) {
setState(() {
_imageProvider = imageProvider;
_loadedFinalImage = true;
});
}
});
}
@override
Widget build(BuildContext context) {
final theme = getEnteColorScheme(context);
final textTheme = getEnteTextTheme(context);
final screenSize = MediaQuery.of(context).size;
// Calculate border intensity based on swipe progress
final borderIntensity = (widget.swipeProgress.abs() * 3).clamp(0.0, 1.0);
final borderWidth = (borderIntensity * 4).clamp(0.0, 4.0); // Thinner border
// Determine border color based on swipe direction
Color? borderColor;
if (widget.isSwipingLeft) {
borderColor = theme.warning700.withValues(alpha: borderIntensity);
} else if (widget.isSwipingRight) {
borderColor = theme.primary700.withValues(alpha: borderIntensity);
}
// Calculate card dimensions to preserve aspect ratio
final maxWidth = screenSize.width * 0.85;
// Reserve space for file info text (approximately 60px) + padding
final maxHeight = screenSize.height * 0.65 - 80;
// Get file info
final fileName = widget.file.displayName;
final fileSize = formatBytes(widget.file.fileSize ?? 0);
// Wrap content tightly - no fixed sizes
final Widget imageWidget = _imageProvider != null
? Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 10,
offset: const Offset(0, 5),
),
],
// Apply border directly when swiping
border: borderColor != null && borderWidth > 0
? Border.all(
color: borderColor,
width: borderWidth,
)
: null,
),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
// Use constraints only on the Image itself
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: maxWidth,
maxHeight: maxHeight,
),
child: Image(
image: _imageProvider!,
fit: BoxFit.contain,
gaplessPlayback: true,
),
),
),
)
: Container(
width: maxWidth * 0.8,
height: maxHeight * 0.5,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
color: theme.backgroundElevated,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 10,
offset: const Offset(0, 5),
),
],
),
child: const Center(
child: EnteLoadingWidget(),
),
);
return Center(
child: ConstrainedBox(
// Ensure the entire widget fits within reasonable bounds
constraints: BoxConstraints(
maxHeight: screenSize.height * 0.75,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: GestureDetector(
onDoubleTap: () {
// Navigate to detail page with hero animation
routeToPage(
context,
DetailPage(
DetailPageConfiguration(
[widget.file],
0,
"swipe_culling_",
mode: DetailPageMode.minimalistic,
),
),
);
},
child: imageWidget,
),
),
// File info directly below the image (only if showFileInfo is true)
if (widget.showFileInfo)
Container(
width: maxWidth,
padding: const EdgeInsets.only(top: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
fileName,
style: textTheme.body,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
),
const SizedBox(height: 2),
Text(
fileSize,
style: textTheme.small.copyWith(color: theme.textMuted),
textAlign: TextAlign.center,
),
],
),
),
],
),
),
);
}
}

View File

@@ -53,6 +53,8 @@ class _ManageSharedLinkWidgetState extends State<ManageSharedLinkWidget> {
widget.collection!.publicURLs.firstOrNull?.enableDownload ?? true;
final isPasswordEnabled =
widget.collection!.publicURLs.firstOrNull?.passwordEnabled ?? false;
final isJoinEnabled =
widget.collection!.publicURLs.firstOrNull?.enableJoin ?? false;
final enteColorScheme = getEnteColorScheme(context);
final PublicURL url = widget.collection!.publicURLs.firstOrNull!;
final String urlValue =
@@ -94,6 +96,31 @@ class _ManageSharedLinkWidgetState extends State<ManageSharedLinkWidget> {
AppLocalizations.of(context).allowAddPhotosDescription,
),
const SizedBox(height: 24),
if (flagService.internalUser)
MenuItemWidget(
key: ValueKey("Allow join $isJoinEnabled"),
captionedTextWidget: const CaptionedTextWidget(
title: "Allow joining album (i)",
),
alignCaptionedTextToLeft: true,
menuItemColor: getEnteColorScheme(context).fillFaint,
trailingWidget: ToggleSwitchWidget(
value: () => isJoinEnabled,
onChanged: () async {
await _updateUrlSettings(
context,
{'enableJoin': !isJoinEnabled},
);
},
),
),
if (flagService.internalUser)
MenuSectionDescriptionWidget(
content: isCollectEnabled
? "Allow people with link to join your album as Collaborator"
: "Allow people with link to join your album as Viewer",
),
if (flagService.internalUser) const SizedBox(height: 24),
MenuItemWidget(
alignCaptionedTextToLeft: true,
captionedTextWidget: CaptionedTextWidget(

View File

@@ -1,4 +1,4 @@
import "dart:async" show Timer, unawaited;
import "dart:async";
import "package:flutter/foundation.dart" show kDebugMode;
import 'package:flutter/material.dart';
@@ -22,7 +22,6 @@ import "package:photos/theme/text_style.dart";
import 'package:photos/ui/components/buttons/button_widget.dart';
import "package:photos/ui/components/models/button_type.dart";
import "package:photos/ui/components/toggle_switch_widget.dart";
import "package:photos/ui/pages/library_culling/swipe_culling_page.dart";
import "package:photos/ui/viewer/file/detail_page.dart";
import "package:photos/ui/viewer/file/thumbnail_widget.dart";
import "package:photos/utils/delete_file_util.dart";
@@ -144,56 +143,7 @@ class _SimilarImagesPageState extends State<SimilarImagesPage>
elevation: 0,
title: Text(AppLocalizations.of(context).similarImages),
actions: _pageState == SimilarImagesPageState.results
? [
// Show swipe culling icon when filtered groups are available
ListenableBuilder(
listenable: _selectedFiles,
builder: (context, _) {
// Get selected groups (groups with at least one selected file)
final selectedGroups = <SimilarFiles>[];
for (final group in _filteredGroups) {
bool hasSelectedFile = false;
for (final file in group.files) {
if (_selectedFiles.files.contains(file)) {
hasSelectedFile = true;
break;
}
}
if (hasSelectedFile) {
selectedGroups.add(group);
}
}
// Filter out single-image groups and groups with 50+ images
final validGroups = selectedGroups
.where((g) => g.files.length > 1 && g.files.length < 50)
.toList();
if (validGroups.isNotEmpty) {
return IconButton(
icon: const Icon(Icons.view_carousel_rounded),
onPressed: () async {
final result = await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => SwipeCullingPage(
similarFiles: validGroups,
),
),
);
if (result != null && result > 0) {
// Refresh page after deletion
unawaited(_findSimilarImages());
}
},
);
}
return const SizedBox.shrink();
},
),
_getSortMenu(),
]
? [_getSortMenu()]
: null,
),
body: _getBody(),

View File

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

View File

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

View File

@@ -12,7 +12,7 @@ description: ente photos application
# Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
version: 1.2.5+1205
version: 1.2.6+1205
publish_to: none
environment:
@@ -84,7 +84,6 @@ dependencies:
sdk: flutter
flutter_animate: ^4.1.0
flutter_cache_manager: ^3.3.0
flutter_card_swiper: ^7.0.1
flutter_displaymode: ^0.6.0
flutter_easyloading: ^3.0.0
flutter_email_sender: ^7.0.0
@@ -350,7 +349,6 @@ flutter:
- assets/image-editor/
- assets/icons/
- assets/launcher_icon/
- assets/ml/
fonts:
- family: Inter
fonts:

View File

@@ -1,2 +1,6 @@
- Neeraj: (i) Add allow joining album option in manage links
- Neeraj: Fix lock error + improve logViewer
- Laurens: text embedding caching for memories and discover
- Neeraj: (i) Option to send qr for link
- Neeraj: (i) Debug option to enable logViewer
- Neeraj: Potential fix for ios in-app payment
- Neeraj: (i) Debug option to enable logViewer

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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