Compare commits

...

65 Commits

Author SHA1 Message Date
laurenspriem
74994a3b63 Merge branch 'main' into swipe_images 2025-09-10 17:58:08 +05:30
laurenspriem
7d4897b08f Change folders 2025-09-10 17:55:18 +05:30
Neeraj
c82b829fe3 [mobile] Add debug option to enable database logging (#7133)
Add option for internal users to enable database logging in release
builds for debugging purposes.
2025-09-10 17:33:56 +05:30
Neeraj
1dbdb270b4 [mob][i] Allow internal users to enable db logging 2025-09-10 17:28:12 +05:30
Neeraj
1d1efc286f [mob][internal] Add QR code sharing feature for album links (#7132)
## Summary
- Add QR code sharing feature for album links behind internal user flag
- Integrate QR option in manage links and share collection pages  
- Auto-close dialog after share operation for better UX

## Implementation
- **New QrCodeDialogWidget**: Custom dialog with album name, QR code,
and ente branding
- **Share functionality**: Captures QR as image and shares with album
context
- **Feature gating**: Hidden behind `flagService.internalUser` for
internal testing
- **UI integration**: Available in both share collection page and manage
links page
- **Dependencies**: Added `qr_flutter: ^4.1.0` for QR generation

## Test Plan
-  QR code generation works for album URLs
-  Share functionality captures and exports QR as image
-  Dialog auto-closes after share operation
-  Feature properly hidden behind internal user flag
-  UI integrates seamlessly with existing sharing flow
-  Visual hierarchy: QR primary, album name secondary, branding
tertiary
2025-09-10 16:48:36 +05:30
Neeraj
dc500795a1 Add QR code sharing feature for album links
- Add QrCodeDialogWidget with album branding and share functionality
- Integrate QR code option in manage links and share collection pages
- Feature gated behind flagService.internalUser for testing
- QR codes include album name, scannable link, and ente branding
- Auto-close dialog after share operation for better UX
- Add qr_flutter dependency for QR code generation

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-10 16:31:09 +05:30
Neeraj
11afcd92af [server] Support for changing server port (#7131)
## Description
Ref: https://github.com/ente-io/ente/issues/7122

## Tests
Tested locally
2025-09-10 16:22:43 +05:30
Manav Rathi
f20c8caff0 [server] Improve support for idn domains (#7124)
## Description

## Tests
2025-09-10 16:17:12 +05:30
Neeraj Gupta
074f68146f [server] Support for changing server port 2025-09-10 14:47:06 +05:30
Neeraj
68caa3f7c6 [mob] Add in-app log viewer for mobile debugging (#7129)
## Description
Introduces a comprehensive log viewer package for Flutter mobile apps
with:
- Real-time log viewing with filtering by level, logger name, and search
- SQLite-based storage with automatic log rotation (10k entries default)
- Timeline visualization and export functionality
- Integration with SuperLogging for seamless log capture

Only enabled in debug mode to avoid production impact.

## Tests



https://github.com/user-attachments/assets/badb2a4a-a9a2-4aec-b0ae-d825cc4fe23e
2025-09-10 13:53:43 +05:30
Neeraj
5e5d5f4aad Lint fixes for log_viewer 2025-09-10 13:36:39 +05:30
Neeraj
8713dd0707 Do null check before try block 2025-09-10 13:19:33 +05:30
Neeraj
102313f686 Clean up 2025-09-10 13:16:19 +05:30
Neeraj
7ef9fdcaaa Add in-app log viewer for mobile debugging
Introduces a comprehensive log viewer package for Flutter mobile apps with:
- Real-time log viewing with filtering by level, logger name, and search
- SQLite-based storage with automatic log rotation (10k entries default)
- Timeline visualization and export functionality
- Integration with SuperLogging for seamless log capture

Only enabled in debug mode to avoid production impact.

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-10 13:06:05 +05:30
Manav Rathi
d902733809 [mob][photos] symlink for agents.md (#7128)
## Description

symlink for [agents.md](https://agents.md)
2025-09-10 11:59:51 +05:30
laurenspriem
0ef990de5a Make CLAUDE.md agent-agnostic, add AGENTS.md symlink
Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-10 11:22:16 +05:30
Manav Rathi
7722c4e16b Fix command to reload Caddy (#7125)
```
$ sudo systemctl caddy reload
Unknown command verb 'caddy', did you mean 'cat'?
```
2025-09-10 09:23:46 +05:30
Hans Lemuet
6f5fdfb7b7 Fix command to reload Caddy
$ sudo systemctl caddy reload
Unknown command verb 'caddy', did you mean 'cat'?
2025-09-10 02:14:56 +02:00
Neeraj Gupta
135124a487 Improve err handling 2025-09-10 05:07:31 +05:30
Neeraj Gupta
d3c53794cf Add alert for exactDomain mismatch 2025-09-10 04:52:20 +05:30
Neeraj Gupta
270cee8b09 [server] Support for idn domain 2025-09-10 04:40:27 +05:30
Neeraj
9b05cc8c23 [server] Minor improvements in link middleware (#7104)
## Description

## Tests
2025-09-10 04:25:23 +05:30
Manav Rathi
5b6c3e1b6e [destkop] Update typo in translation (#7118)
See: https://github.com/ente-io/ente/pull/5546#issuecomment-3268874821

Updated the strings in crowdin by `gh workflow run
web-crowdin-push-both.yml`
2025-09-09 17:41:34 +05:30
Manav Rathi
636793d5b1 [destkop] Update typo in translation
See: https://github.com/ente-io/ente/pull/5546#issuecomment-3268874821
2025-09-09 17:32:18 +05:30
Manav Rathi
700e52d11a [web] Harden workflows (#7114) 2025-09-09 13:30:32 +05:30
Manav Rathi
82c7d1865c Update 2025-09-09 12:49:08 +05:30
Manav Rathi
f08ee15cea [web] Harden workflows 2025-09-09 12:00:56 +05:30
Laurens Priem
901bfc945e [mob][photos] Fix copy paste mistake in claude.md (#7109)
## Description

Fix copy paste mistake in claude.md
2025-09-08 16:48:46 +05:30
laurenspriem
6c25b094be Fix copy paste mistake in claude.md 2025-09-08 16:47:21 +05:30
Neeraj Gupta
575314c8a1 [server] Relax origin check for localhost for dev 2025-09-08 14:37:22 +05:30
Neeraj Gupta
2684f9ce11 [server] whitelist shared url 2025-09-08 14:33:31 +05:30
laurenspriem
20afda080d Merge branch 'main' into swipe_images 2025-09-04 16:18:37 +05:30
laurenspriem
b95640f0f7 Update todo 2025-09-04 15:58:26 +05:30
laurenspriem
92974a5d6e Update todo 2025-09-04 15:54:11 +05:30
laurenspriem
a26643d932 Stack all cards 2025-09-04 15:42:20 +05:30
laurenspriem
757cb486f8 Add smooth undo animation with AnimatedSwitcher
Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-04 12:29:31 +05:30
laurenspriem
512cbb29b3 Implement cross-group undo behavior
- Undo button now navigates to last group with changes when current group has no history
- Automatically jumps to the correct image position after cross-group undo
- Keeps controller alive during group switches for better state management
2025-09-04 12:10:01 +05:30
laurenspriem
0bc8029541 Fix carousel darkening caused by ColorFilter bleed
Replace ColorFilter.darken with isolated opacity and overlay to prevent
affecting carousel brightness when preview card is shown.

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-04 11:38:41 +05:30
laurenspriem
b5febe9ba4 Add stacked image preview with subtle top peek 2025-09-04 10:49:37 +05:30
laurenspriem
fb0179b081 Add double-tap to zoom functionality for swipeable images 2025-09-04 10:25:46 +05:30
laurenspriem
289f969d08 Use circular replay icon for undo button 2025-09-04 10:14:42 +05:30
laurenspriem
1b60a4fb38 delete accidentally commited screenshots 2025-09-04 09:43:29 +05:30
laurenspriem
9000529509 Fix pixel overflow for tall portrait images 2025-09-03 22:50:32 +05:30
laurenspriem
18872a329a Fix card border issue - Container wraps image tightly without fixed sizes 2025-09-03 22:43:48 +05:30
laurenspriem
dc843eb618 Fix card border issue - apply constraints to Image not Container 2025-09-03 22:41:53 +05:30
laurenspriem
009dec1e08 Fix card centering and keep file info with image 2025-09-03 22:29:04 +05:30
laurenspriem
82032a182b Mark all remaining fixes as complete in progress.md 2025-09-03 22:22:42 +05:30
laurenspriem
4bd21e2a7a Improve bottom button styling with proper sizing and Ente design 2025-09-03 22:22:18 +05:30
laurenspriem
278dd83a3d Fix swipe card pixel overflow by separating file info from card 2025-09-03 22:20:49 +05:30
laurenspriem
0a04c3b4aa Remove duplicate file info display 2025-09-03 22:11:11 +05:30
laurenspriem
6b760fd611 Fix badge display for partially processed groups 2025-09-03 21:53:23 +05:30
laurenspriem
2518bf5b2d Update todo 2025-09-03 18:33:44 +05:30
laurenspriem
a0eb38c584 Improve swipe culling UI and functionality
- Group summary popup: Changed header to "Decisions", updated button text to "Confirm" and "Undo decisions"
- Added file name and size display below thumbnails in grid view
- Made thumbnails square with better aspect ratio
- Implemented consistent badge design (delete, keep, undecided) in top-right corner
- Added long press to zoom into images with hero animation
- Hide "Undo decisions" button when no decisions made
- Progressive image loading in swipeable photo card (cache → large thumbnail → full)
- Auto-scroll carousel to follow group progression
- Larger action buttons (100x100) positioned higher on screen
- Left-aligned text under thumbnails for better visual alignment

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-03 18:33:02 +05:30
laurenspriem
e32ac8d3b5 Update todos 2025-09-03 16:54:10 +05:30
laurenspriem
1d02d18937 Refine swipe culling UI with improved visual design
• Rectangular thumbnails (72x90px) with better stacking effect
• Improved delete button design matching small delete buttons
• Fixed clipping issues with rotated carousel thumbnails
• Enhanced badge visibility with white text/icons
• Better spacing and visual hierarchy throughout

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-03 16:46:16 +05:30
laurenspriem
c34cfdcb54 merge main 2025-09-03 13:40:16 +05:30
laurenspriem
ef66d422b7 Fix swipe culling UI issues and improve user experience 2025-09-02 15:39:44 +05:30
laurenspriem
dc5c3d8af6 Merge branch 'main' into swipe_images 2025-09-02 14:03:17 +05:30
laurenspriem
bb5c0db8d3 Fix all Flutter analyze warnings 2025-09-02 10:32:08 +05:30
laurenspriem
76c5f5cbb6 Fix all Flutter analyze errors and warnings 2025-09-02 10:28:03 +05:30
laurenspriem
5446f8dd68 Fix localization imports and linting issues 2025-09-02 10:06:23 +05:30
laurenspriem
52e3f22abf Complete swipe culling feature implementation 2025-09-02 09:44:14 +05:30
laurenspriem
0caaf8b966 Add swipe culling feature with CardSwiper implementation 2025-09-02 09:39:00 +05:30
laurenspriem
06a05659a6 Add implementation progress tracker for swipe culling 2025-09-01 17:14:47 +05:30
laurenspriem
f014a36eba Add photo swipe culling feature spec and implementation plan 2025-09-01 16:06:05 +05:30
55 changed files with 7468 additions and 30 deletions

View File

@@ -29,6 +29,8 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
persist-credentials: false
- name: Setup node and enable yarn caching
uses: actions/setup-node@v4
@@ -38,7 +40,7 @@ jobs:
cache-dependency-path: "web/yarn.lock"
- name: Install dependencies
run: yarn install
run: yarn install --frozen-lockfile
- name: Build ${{ inputs.app }}
run: yarn build:${{ inputs.app }}

View File

@@ -29,6 +29,8 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
persist-credentials: false
- name: Setup node and enable yarn caching
uses: actions/setup-node@v4
@@ -38,7 +40,7 @@ jobs:
cache-dependency-path: "web/yarn.lock"
- name: Install dependencies
run: yarn install
run: yarn install --frozen-lockfile
- name: Build ${{ inputs.app }}
run: yarn build:${{ inputs.app }}

View File

@@ -37,6 +37,7 @@ jobs:
uses: actions/checkout@v4
with:
ref: ${{ steps.select-branch.outputs.branch }}
persist-credentials: false
- name: Setup node and enable yarn caching
uses: actions/setup-node@v4
@@ -46,7 +47,7 @@ jobs:
cache-dependency-path: "web/yarn.lock"
- name: Install dependencies
run: yarn install
run: yarn install --frozen-lockfile
- name: Build photos
run: yarn build:photos

View File

@@ -33,6 +33,8 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
persist-credentials: false
- name: Setup node and enable yarn caching
uses: actions/setup-node@v4
@@ -42,7 +44,15 @@ jobs:
cache-dependency-path: "web/yarn.lock"
- name: Install dependencies
run: yarn install
run: yarn install --frozen-lockfile
- name: Audit dependencies
run: |
yarn audit --level critical || exit_code=$?
if [[ $exit_code -ge 16 ]]; then
echo "::error::Yarn audit found critical issues"
exit 1
fi
- name: Build photos
run: yarn build:photos

View File

@@ -24,6 +24,8 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
persist-credentials: false
- name: Setup node and enable yarn caching
uses: actions/setup-node@v4
@@ -32,6 +34,14 @@ jobs:
cache: "yarn"
cache-dependency-path: "web/yarn.lock"
- run: yarn install
- run: yarn install --frozen-lockfile
- run: yarn lint
- name: Audit dependencies
run: |
yarn audit --level critical || exit_code=$?
if [[ $exit_code -ge 16 ]]; then
echo "::error::Yarn audit found critical issues"
exit 1
fi

113
CLAUDE.md Normal file
View File

@@ -0,0 +1,113 @@
# 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

@@ -89,7 +89,7 @@ cast.ente.yourdomain.tld {
Reload Caddy for changes to take effect.
```shell
sudo systemctl caddy reload
sudo systemctl reload caddy
```
## Step 4: Verify the setup

View File

@@ -0,0 +1,795 @@
# 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

@@ -0,0 +1,335 @@
# 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.

After

Width:  |  Height:  |  Size: 196 KiB

View File

@@ -0,0 +1,134 @@
# 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.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

View File

@@ -0,0 +1 @@
CLAUDE.md

View File

@@ -1,6 +1,6 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
This file provides guidance to Claude, Codex, and any other agent when working with code in this repository.
## Project Philosophy
@@ -205,4 +205,4 @@ lib/
- Always follow existing code conventions and patterns in neighboring files
# Individual Preferences
- @~/.claude/my-project-instructions.md
- @~/.claude/ente-photos-instructions.md

View File

@@ -5,9 +5,10 @@ import 'dart:io';
import "package:dio/dio.dart";
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:intl/intl.dart';
import 'package:log_viewer/log_viewer.dart';
import 'package:logging/logging.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:path/path.dart';
@@ -218,6 +219,19 @@ 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) {
@@ -297,6 +311,17 @@ 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) {
@@ -314,6 +339,15 @@ 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;
@@ -455,4 +489,15 @@ class SuperLogging {
final pkgName = (await PackageInfo.fromPlatform()).packageName;
return pkgName.startsWith("io.ente.photos.fdroid");
}
/// Show the log viewer page
/// This is the main integration point for accessing the log viewer
static void showLogViewer(BuildContext context) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const LogViewerPage(),
),
);
}
}

View File

@@ -1906,6 +1906,72 @@
},
"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

@@ -0,0 +1,32 @@
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

@@ -0,0 +1,971 @@
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

@@ -0,0 +1,287 @@
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

@@ -0,0 +1,247 @@
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

@@ -0,0 +1,273 @@
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

@@ -23,6 +23,7 @@ class DebugSectionWidget extends StatefulWidget {
}
class _DebugSectionWidgetState extends State<DebugSectionWidget> {
@override
Widget build(BuildContext context) {
return ExpandableMenuItemWidget(
@@ -35,6 +36,27 @@ class _DebugSectionWidgetState extends State<DebugSectionWidget> {
Widget _getSectionOptions(BuildContext context) {
return Column(
children: [
sectionOptionSpacing,
MenuItemWidget(
captionedTextWidget: const CaptionedTextWidget(
title: "Enable database logging",
),
pressedColor: getEnteColorScheme(context).fillFaint,
trailingWidget: ToggleSwitchWidget(
value: () => localSettings.enableDatabaseLogging,
onChanged: () async {
final newValue = !localSettings.enableDatabaseLogging;
await localSettings.setEnableDatabaseLogging(newValue);
setState(() {});
showShortToast(
context,
newValue
? "Database logging enabled. Restart app."
: "Database logging disabled. Restart app.",
);
},
),
),
sectionOptionSpacing,
MenuItemWidget(
captionedTextWidget: const CaptionedTextWidget(

View File

@@ -3,6 +3,7 @@ import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import "package:flutter_animate/flutter_animate.dart";
import "package:log_viewer/log_viewer.dart";
import 'package:photos/core/configuration.dart';
import 'package:photos/core/event_bus.dart';
import 'package:photos/events/opened_settings_event.dart';
@@ -35,6 +36,7 @@ class SettingsPage extends StatelessWidget {
const SettingsPage({super.key, required this.emailNotifier});
@override
Widget build(BuildContext context) {
Bus.instance.fire(OpenedSettingsEvent());
@@ -70,12 +72,36 @@ class SettingsPage extends StatelessWidget {
// [AnimatedBuilder] accepts any [Listenable] subtype.
animation: emailNotifier,
builder: (BuildContext context, Widget? child) {
return Text(
emailNotifier.value!,
style: enteTextTheme.body.copyWith(
color: colorScheme.textMuted,
overflow: TextOverflow.ellipsis,
),
return Row(
children: [
Expanded(
child: Text(
emailNotifier.value!,
style: enteTextTheme.body.copyWith(
color: colorScheme.textMuted,
overflow: TextOverflow.ellipsis,
),
),
),
if (localSettings.enableDatabaseLogging)
GestureDetector(
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const LogViewerPage(),
),
);
},
child: Container(
padding: const EdgeInsets.all(8),
child: Icon(
Icons.bug_report,
size: 20,
color: colorScheme.textMuted,
),
),
),
],
);
},
),

View File

@@ -7,6 +7,7 @@ import "package:flutter/services.dart";
import "package:photos/generated/l10n.dart";
import "package:photos/models/api/collection/public_url.dart";
import 'package:photos/models/collection/collection.dart';
import 'package:photos/service_locator.dart';
import 'package:photos/services/collections_service.dart';
import 'package:photos/theme/colors.dart';
import 'package:photos/theme/ente_theme.dart';
@@ -19,6 +20,7 @@ import "package:photos/ui/components/toggle_switch_widget.dart";
import 'package:photos/ui/notification/toast.dart';
import 'package:photos/ui/sharing/pickers/device_limit_picker_page.dart';
import 'package:photos/ui/sharing/pickers/link_expiry_picker_page.dart';
import 'package:photos/ui/sharing/qr_code_dialog_widget.dart';
import 'package:photos/utils/dialog_util.dart';
import 'package:photos/utils/navigation_util.dart';
import "package:photos/utils/share_util.dart";
@@ -291,6 +293,32 @@ class _ManageSharedLinkWidgetState extends State<ManageSharedLinkWidget> {
);
},
isTopBorderRadiusRemoved: true,
isBottomBorderRadiusRemoved: flagService.internalUser,
),
if (!url.isExpired && flagService.internalUser)
DividerWidget(
dividerType: DividerType.menu,
bgColor: getEnteColorScheme(context).fillFaint,
),
if (!url.isExpired && flagService.internalUser)
MenuItemWidget(
captionedTextWidget: const CaptionedTextWidget(
title: "Send QR Code (i)",
makeTextBold: true,
),
leadingIcon: Icons.qr_code_outlined,
menuItemColor: getEnteColorScheme(context).fillFaint,
onTap: () async {
await showDialog<void>(
context: context,
builder: (BuildContext context) {
return QrCodeDialogWidget(
collection: widget.collection!,
);
},
);
},
isTopBorderRadiusRemoved: true,
),
const SizedBox(
height: 24,

View File

@@ -0,0 +1,211 @@
import 'dart:io';
import 'dart:math';
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:path_provider/path_provider.dart';
import 'package:photos/models/collection/collection.dart';
import 'package:photos/services/collections_service.dart';
import 'package:photos/theme/ente_theme.dart';
import 'package:photos/ui/components/buttons/button_widget.dart';
import 'package:photos/ui/components/models/button_type.dart';
import 'package:qr_flutter/qr_flutter.dart';
import 'package:share_plus/share_plus.dart';
class QrCodeDialogWidget extends StatefulWidget {
final Collection collection;
const QrCodeDialogWidget({
super.key,
required this.collection,
});
@override
State<QrCodeDialogWidget> createState() => _QrCodeDialogWidgetState();
}
class _QrCodeDialogWidgetState extends State<QrCodeDialogWidget> {
final GlobalKey _qrKey = GlobalKey();
Future<void> _shareQrCode() async {
try {
final RenderRepaintBoundary boundary =
_qrKey.currentContext!.findRenderObject() as RenderRepaintBoundary;
final ui.Image image = await boundary.toImage(pixelRatio: 3.0);
final ByteData? byteData =
await image.toByteData(format: ui.ImageByteFormat.png);
if (byteData != null) {
final Uint8List pngBytes = byteData.buffer.asUint8List();
final directory = await getTemporaryDirectory();
final file = File(
'${directory.path}/ente_qr_${widget.collection.displayName}.png',
);
await file.writeAsBytes(pngBytes);
await Share.shareXFiles(
[XFile(file.path)],
text:
'Scan this QR code to view my ${widget.collection.displayName} album on ente',
);
// Close the dialog after sharing is initiated
if (mounted) {
Navigator.of(context).pop();
}
}
} catch (e) {
debugPrint('Error sharing QR code: $e');
}
}
@override
Widget build(BuildContext context) {
final screenWidth = MediaQuery.of(context).size.width;
final double qrSize = min(screenWidth - 80, 300.0);
final enteTextTheme = getEnteTextTheme(context);
final enteColorScheme = getEnteColorScheme(context);
// Get the public URL for the collection
final String publicUrl =
CollectionsService.instance.getPublicUrl(widget.collection);
// Get album name, truncate if too long
final String albumName = widget.collection.displayName.length > 30
? '${widget.collection.displayName.substring(0, 27)}...'
: widget.collection.displayName;
return Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: enteColorScheme.backgroundBase,
borderRadius: BorderRadius.circular(12),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Header with close button
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
"QR Code",
style: enteTextTheme.largeBold,
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.of(context).pop(),
color: enteColorScheme.strokeBase,
),
],
),
const SizedBox(height: 8),
// QR Code with RepaintBoundary for sharing
RepaintBoundary(
key: _qrKey,
child: Container(
padding: const EdgeInsets.all(28),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: Colors.grey.shade200,
width: 1,
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.04),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Stack(
children: [
Column(
mainAxisSize: MainAxisSize.min,
children: [
// Album name at top center (inside border) - Reduced size
Text(
albumName,
style: enteTextTheme.bodyBold.copyWith(
color: Colors.black87,
fontSize: 16,
fontWeight: FontWeight.w600,
letterSpacing: 0.3,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 20),
// QR Code with better spacing
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: Colors.grey.shade100,
width: 1,
),
),
child: QrImageView(
data: publicUrl,
version: QrVersions.auto,
size: qrSize - 100,
eyeStyle: const QrEyeStyle(
eyeShape: QrEyeShape.square,
color: Colors.black,
),
dataModuleStyle: const QrDataModuleStyle(
dataModuleShape: QrDataModuleShape.square,
color: Colors.black,
),
errorCorrectionLevel: QrErrorCorrectLevel.M,
),
),
const SizedBox(height: 24),
],
),
// Ente branding at bottom right (inside border) - Fixed positioning
Positioned(
bottom: -2,
right: 2,
child: Text(
'ente',
style: enteTextTheme.small.copyWith(
color: enteColorScheme.primary700,
fontSize: 14,
letterSpacing: 1.2,
fontWeight: FontWeight.w600,
),
),
),
],
),
),
),
const SizedBox(height: 24),
// Share button
ButtonWidget(
buttonType: ButtonType.primary,
icon: Icons.adaptive.share,
labelText: "Share",
onTap: _shareQrCode,
shouldSurfaceExecutionStates: false,
),
],
),
),
);
}
}

View File

@@ -1,10 +1,12 @@
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import "package:photos/extensions/user_extension.dart";
import "package:photos/generated/l10n.dart";
import "package:photos/models/api/collection/user.dart";
import 'package:photos/models/collection/collection.dart';
import 'package:photos/service_locator.dart';
import 'package:photos/services/collections_service.dart';
import 'package:photos/theme/ente_theme.dart';
import 'package:photos/ui/actions/collection/collection_sharing_actions.dart';
@@ -19,6 +21,7 @@ import 'package:photos/ui/sharing/album_participants_page.dart';
import "package:photos/ui/sharing/album_share_info_widget.dart";
import "package:photos/ui/sharing/manage_album_participant.dart";
import 'package:photos/ui/sharing/manage_links_widget.dart';
import 'package:photos/ui/sharing/qr_code_dialog_widget.dart';
import 'package:photos/ui/sharing/user_avator_widget.dart';
import 'package:photos/utils/navigation_util.dart';
import 'package:photos/utils/share_util.dart';
@@ -214,8 +217,34 @@ class _ShareCollectionPageState extends State<ShareCollectionPage> {
);
},
isTopBorderRadiusRemoved: true,
isBottomBorderRadiusRemoved: true,
isBottomBorderRadiusRemoved: flagService.internalUser,
),
if (flagService.internalUser)
DividerWidget(
dividerType: DividerType.menu,
bgColor: getEnteColorScheme(context).fillFaint,
),
if (flagService.internalUser)
MenuItemWidget(
captionedTextWidget: const CaptionedTextWidget(
title: "Send QR Code (i)",
makeTextBold: true,
),
leadingIcon: Icons.qr_code_outlined,
menuItemColor: getEnteColorScheme(context).fillFaint,
onTap: () async {
await showDialog<void>(
context: context,
builder: (BuildContext context) {
return QrCodeDialogWidget(
collection: widget.collection,
);
},
);
},
isTopBorderRadiusRemoved: true,
isBottomBorderRadiusRemoved: true,
),
],
);
}

View File

@@ -1,4 +1,4 @@
import "dart:async";
import "dart:async" show Timer, unawaited;
import "package:flutter/foundation.dart" show kDebugMode;
import 'package:flutter/material.dart';
@@ -22,6 +22,7 @@ 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";
@@ -143,7 +144,56 @@ class _SimilarImagesPageState extends State<SimilarImagesPage>
elevation: 0,
title: Text(AppLocalizations.of(context).similarImages),
actions: _pageState == SimilarImagesPageState.results
? [_getSortMenu()]
? [
// 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(),
]
: null,
),
body: _getBody(),

View File

@@ -1,3 +1,5 @@
import 'package:flutter/foundation.dart';
import 'package:photos/core/constants.dart';
import 'package:photos/ui/viewer/gallery/component/group/type.dart';
import "package:photos/utils/ram_check_util.dart";
@@ -42,6 +44,7 @@ class LocalSettings {
static const kCollectionViewType = "collection_view_type";
static const kCollectionSortDirection = "collection_sort_direction";
static const kShowLocalIDOverThumbnails = "show_local_id_over_thumbnails";
static const kEnableDatabaseLogging = "enable_db_logging";
// Thumbnail queue configuration keys
static const kSmallQueueMaxConcurrent = "small_queue_max_concurrent";
@@ -234,6 +237,13 @@ class LocalSettings {
await _prefs.setBool(kShowLocalIDOverThumbnails, value);
}
bool get enableDatabaseLogging =>
_prefs.getBool(kEnableDatabaseLogging) ?? kDebugMode;
Future<void> setEnableDatabaseLogging(bool value) async {
await _prefs.setBool(kEnableDatabaseLogging, value);
}
// Thumbnail queue configuration - Small queue
int get smallQueueMaxConcurrent =>
_prefs.getInt(kSmallQueueMaxConcurrent) ?? 15;

View File

@@ -785,6 +785,14 @@ 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:
@@ -1512,6 +1520,13 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.11"
log_viewer:
dependency: "direct main"
description:
path: "../../packages/log_viewer"
relative: true
source: path
version: "1.0.0"
logger:
dependency: transitive
description:

View File

@@ -84,6 +84,7 @@ 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
@@ -123,6 +124,8 @@ dependencies:
local_auth: ^2.1.5
local_auth_android:
local_auth_ios:
log_viewer:
path: ../../packages/log_viewer
logging: ^1.3.0
lottie: ^3.3.1
maps_launcher: ^3.0.0+1
@@ -175,6 +178,7 @@ dependencies:
url: https://github.com/ente-io/privacy_screen.git
ref: v2-only
pro_image_editor: ^6.0.0
qr_flutter: ^4.1.0
receive_sharing_intent: # pub.dev is behind
git:
url: https://github.com/KasemJaffer/receive_sharing_intent.git

View File

@@ -1,4 +1,4 @@
# melos_managed_dependency_overrides: ente_cast,ente_cast_normal,ente_crypto,ente_feature_flag,onnx_dart,ffi,flutter_sodium,intl,js,media_kit,media_kit_libs_ios_video,media_kit_libs_video,media_kit_video,protobuf,video_player,watcher,win32
# melos_managed_dependency_overrides: ente_cast,ente_cast_normal,ente_crypto,ente_feature_flag,onnx_dart,ffi,flutter_sodium,intl,js,media_kit,media_kit_libs_ios_video,media_kit_libs_video,media_kit_video,protobuf,video_player,watcher,win32,log_viewer
dependency_overrides:
ente_cast:
path: plugins/ente_cast
@@ -41,3 +41,5 @@ dependency_overrides:
path: packages/video_player/video_player/
watcher: ^1.1.0
win32: 5.10.1
log_viewer:
path: ../../packages/log_viewer

View File

@@ -1 +1,2 @@
- Neeraj: Potential fix for ios in-app payment
- Neeraj: Potential fix for ios in-app payment
- Neeraj: (i) Debug option to enable logViewer

View File

@@ -0,0 +1,56 @@
# Log Viewer
A Flutter package that provides an in-app log viewer with advanced filtering capabilities for Ente apps.
## Features
- 📝 Real-time log capture and display
- 🔍 Advanced filtering by logger name, log level, and text search
- 🎨 Color-coded log levels for easy identification
- 📊 SQLite-based storage with automatic truncation
- 📤 Export filtered logs as text
- ⚡ Performance optimized with batch inserts and indexing
## Usage
### 1. Initialize in your app
```dart
import 'package:log_viewer/log_viewer.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Initialize log viewer
await LogViewer.initialize();
runApp(MyApp());
}
```
### 2. Open the log viewer
```dart
// As a navigation action
LogViewer.openViewer(context);
// Or embed as a widget
LogViewer.getViewerPage()
```
### 3. The log viewer will automatically capture all logs
The package integrates with the Ente logging system to automatically capture and store logs.
## Filtering Options
- **Logger Name**: Filter by specific loggers (e.g., "auth", "sync", "ui")
- **Log Level**: Filter by severity (SEVERE, WARNING, INFO, etc.)
- **Text Search**: Search within log messages and error descriptions
- **Time Range**: Filter logs by date/time range
## Database Management
- Logs are stored in a local SQLite database
- By default, automatic truncation keeps only the most recent 10000 entries
- Batch inserts for optimal performance

View File

@@ -0,0 +1 @@
include: ../../analysis_options.yaml

View File

@@ -0,0 +1,327 @@
# Log Viewer Integration Examples
This document provides examples of integrating the log_viewer package into your Flutter application, both as a standalone solution and integrated with SuperLogging.
## Standalone Integration (Without SuperLogging)
### Basic Setup
```dart
import 'package:flutter/material.dart';
import 'package:log_viewer/log_viewer.dart';
import 'package:logging/logging.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Initialize the log viewer with custom configuration
await LogViewer.initialize(
maxEntries: 5000, // Optional: default is 10000
);
// 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}');
});
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Log Viewer Example',
home: MyHomePage(),
);
}
}
class MyHomePage extends StatefulWidget {
@override
_MyHomePageState createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
final Logger _logger = Logger('MyHomePage');
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Log Viewer Example'),
actions: [
IconButton(
icon: Icon(Icons.bug_report),
onPressed: () {
// Navigate to log viewer
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const LogViewerPage(),
),
);
},
),
],
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ElevatedButton(
onPressed: () {
_logger.info('Info log message');
},
child: Text('Log Info'),
),
ElevatedButton(
onPressed: () {
_logger.warning('Warning log message');
},
child: Text('Log Warning'),
),
ElevatedButton(
onPressed: () {
try {
throw Exception('Test error');
} catch (e, s) {
_logger.severe('Error occurred', e, s);
}
},
child: Text('Log Error'),
),
],
),
),
);
}
}
```
## SuperLogging Integration (Ente Photos Style)
### Complete Integration Example
```dart
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:log_viewer/log_viewer.dart';
import 'package:logging/logging.dart';
import 'package:path_provider/path_provider.dart';
// SuperLogging-like configuration
class LogConfig {
final String? logDirPath;
final int maxLogFiles;
final bool enableInDebugMode;
final FutureOrVoidCallback? body;
final String prefix;
LogConfig({
this.logDirPath,
this.maxLogFiles = 10,
this.enableInDebugMode = false,
this.body,
this.prefix = "",
});
}
class SuperLogging {
static final Logger _logger = Logger('SuperLogging');
static late LogConfig config;
static Future<void> main([LogConfig? appConfig]) async {
appConfig ??= LogConfig();
SuperLogging.config = appConfig;
WidgetsFlutterBinding.ensureInitialized();
// Initialize log viewer in debug mode 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");
}
}
Logger.root.level = kDebugMode ? Level.ALL : Level.INFO;
Logger.root.onRecord.listen(onLogRecord);
if (appConfig.body != null) {
await appConfig.body!();
}
}
static Future<void> onLogRecord(LogRecord rec) async {
final str = "${config.prefix} ${rec.toString()}";
// Print to console
if (kDebugMode) {
print(str);
}
// 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;
}
}
// Main application with SuperLogging integration
Future<void> main() async {
await SuperLogging.main(
LogConfig(
body: () async {
runApp(MyApp());
},
logDirPath: (await getApplicationSupportDirectory()).path + "/logs",
maxLogFiles: 5,
enableInDebugMode: true,
prefix: "[APP]",
),
);
}
```
### Ente Photos Integration Example
In your Ente Photos app's main.dart, add the log viewer initialization in the `runWithLogs` function:
```dart
Future runWithLogs(Function() function, {String prefix = ""}) async {
await SuperLogging.main(
LogConfig(
body: function,
logDirPath: (await getApplicationSupportDirectory()).path + "/logs",
maxLogFiles: 5,
sentryDsn: kDebugMode ? sentryDebugDSN : sentryDSN,
tunnel: sentryTunnel,
enableInDebugMode: true,
prefix: prefix,
),
);
// 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");
}
}
}
```
### Settings Page Integration
Add log viewer access in your settings page (debug mode only):
```dart
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:log_viewer/log_viewer.dart';
class SettingsPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
body: Column(
children: [
// Existing email/user info with debug button
Row(
children: [
Expanded(
child: Text(
'user@example.com',
style: TextStyle(color: Colors.grey),
),
),
if (kDebugMode)
GestureDetector(
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const LogViewerPage(),
),
);
},
child: Container(
padding: const EdgeInsets.all(8),
child: Icon(
Icons.bug_report,
size: 20,
color: Colors.grey,
),
),
),
],
),
// Other settings items...
],
),
);
}
}
```
## Features Available
Once integrated, users will have access to:
1. **Real-time log viewing** - Logs appear as they're generated
2. **Filtering by log level** - Show only errors, warnings, info, etc.
3. **Filtering by logger name** - Focus on specific components
4. **Text search** - Search within log messages and errors
5. **Date range filtering** - View logs from specific time periods
6. **Export functionality** - Share logs as text files
7. **Detailed view** - Tap any log to see full details including stack traces
## How It Works
1. The `log_viewer` package listens to all logs via `Logger.root.onRecord`
2. Logs are stored in a local SQLite database (auto-truncated to 2000 entries)
3. The UI provides filtering and search capabilities
4. The integration with `super_logging` is automatic - no changes needed
## Troubleshooting
If logs aren't appearing:
1. Ensure `LogViewer.initialize()` is called after logging is set up
2. Check that the app has write permissions for the database
3. Verify that `Logger.root.level` is set appropriately (not OFF)
## Performance Notes
- Logs are buffered and batch-inserted for optimal performance
- Database is indexed for fast filtering
- UI updates are debounced to avoid excessive refreshes
- Old logs are automatically cleaned up

View File

@@ -0,0 +1,97 @@
import 'package:flutter/material.dart';
import 'package:log_viewer/src/core/log_store.dart';
import 'package:log_viewer/src/ui/log_viewer_page.dart';
import 'package:logging/logging.dart' as log;
export 'src/core/log_database.dart';
export 'src/core/log_models.dart';
// Core exports
export 'src/core/log_store.dart';
export 'src/ui/log_detail_page.dart';
export 'src/ui/log_filter_dialog.dart';
export 'src/ui/log_list_tile.dart';
// UI exports
export 'src/ui/log_viewer_page.dart';
/// Main entry point for the log viewer functionality
class LogViewer {
static bool _initialized = false;
/// Initialize the log viewer
/// This should be called once during app startup
static Future<void> initialize() async {
if (_initialized) return;
// Initialize the log store
await LogStore.instance.initialize();
// Register callback with super_logging if available
_registerWithSuperLogging();
_initialized = true;
}
/// Register callback with super_logging to receive logs
static void _registerWithSuperLogging() {
// Try to register with SuperLogging if available
try {
// This will be called dynamically by the main app if SuperLogging is available
// For now, fallback to direct logger listening without prefix
log.Logger.root.onRecord.listen((record) {
LogStore.addLogRecord(record, '');
});
} catch (e) {
// SuperLogging not available, fallback to direct logger
log.Logger.root.onRecord.listen((record) {
LogStore.addLogRecord(record, '');
});
}
}
/// 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.',);
}
return const LogViewerPage();
}
/// Open the log viewer in a new route
static Future<void> openViewer(BuildContext context) async {
if (!_initialized) {
await initialize();
}
await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const LogViewerPage(),
),
);
}
/// Check if log viewer is initialized
static bool get isInitialized => _initialized;
/// Dispose of log viewer resources
static Future<void> dispose() async {
if (_initialized) {
await LogStore.instance.dispose();
_initialized = false;
}
}
}

View File

@@ -0,0 +1,411 @@
import 'dart:async';
import 'package:log_viewer/src/core/log_models.dart';
import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';
import 'package:sqflite/sqflite.dart';
/// Manages SQLite database for log storage
class LogDatabase {
static const String _databaseName = 'log_viewer.db';
static const String _tableName = 'logs';
static const int _databaseVersion = 1;
final int maxEntries;
Database? _database;
LogDatabase({this.maxEntries = 10000});
/// Get database instance
Future<Database> get database async {
_database ??= await _initDatabase();
return _database!;
}
/// Initialize database
Future<Database> _initDatabase() async {
final documentsDirectory = await getApplicationDocumentsDirectory();
final path = join(documentsDirectory.path, _databaseName);
return await openDatabase(
path,
version: _databaseVersion,
onCreate: _onCreate,
onOpen: _onOpen,
);
}
/// Create database tables
Future<void> _onCreate(Database db, int version) async {
await db.execute('''
CREATE TABLE $_tableName(
id INTEGER PRIMARY KEY AUTOINCREMENT,
message TEXT NOT NULL,
level TEXT NOT NULL,
timestamp INTEGER NOT NULL,
logger_name TEXT NOT NULL,
error TEXT,
stack_trace TEXT,
process_prefix TEXT NOT NULL DEFAULT ''
)
''');
// Minimal indexes for write performance - only timestamp for ordering
await db.execute(
'CREATE INDEX idx_timestamp ON $_tableName(timestamp DESC)',
);
}
/// Called when database is opened
Future<void> _onOpen(Database db) async {
// Enable write-ahead logging for better performance
// Use rawQuery for PRAGMA commands to avoid permission issues
await db.rawQuery('PRAGMA journal_mode = WAL');
}
/// Insert a single log entry
Future<int> insertLog(LogEntry entry) async {
final db = await database;
final id = await db.insert(_tableName, entry.toMap());
// Auto-truncate if needed
await _truncateIfNeeded(db);
return id;
}
/// Insert multiple log entries in a batch
Future<void> insertLogs(List<LogEntry> entries) async {
if (entries.isEmpty) return;
final db = await database;
final batch = db.batch();
for (final entry in entries) {
batch.insert(_tableName, entry.toMap());
}
await batch.commit(noResult: true);
await _truncateIfNeeded(db);
}
/// Get logs with optional filtering
Future<List<LogEntry>> getLogs({
LogFilter? filter,
int limit = 250,
int offset = 0,
}) async {
final db = await database;
// Build WHERE clause
final conditions = <String>[];
final args = <dynamic>[];
if (filter != null) {
// Logger filter
if (filter.selectedLoggers.isNotEmpty) {
final placeholders =
List.filled(filter.selectedLoggers.length, '?').join(',');
conditions.add('logger_name IN ($placeholders)');
args.addAll(filter.selectedLoggers);
}
// Level filter
if (filter.selectedLevels.isNotEmpty) {
final placeholders =
List.filled(filter.selectedLevels.length, '?').join(',');
conditions.add('level IN ($placeholders)');
args.addAll(filter.selectedLevels);
}
// Process prefix filter
if (filter.selectedProcesses.isNotEmpty) {
final placeholders =
List.filled(filter.selectedProcesses.length, '?').join(',');
conditions.add('process_prefix IN ($placeholders)');
args.addAll(filter.selectedProcesses);
}
// Search query
if (filter.searchQuery != null && filter.searchQuery!.isNotEmpty) {
conditions.add('(message LIKE ? OR error LIKE ?)');
final searchPattern = '%${filter.searchQuery}%';
args.add(searchPattern);
args.add(searchPattern);
}
// Time range
if (filter.startTime != null) {
conditions.add('timestamp >= ?');
args.add(filter.startTime!.millisecondsSinceEpoch);
}
if (filter.endTime != null) {
conditions.add('timestamp <= ?');
args.add(filter.endTime!.millisecondsSinceEpoch);
}
}
final whereClause = conditions.isEmpty ? null : conditions.join(' AND ');
final results = await db.query(
_tableName,
where: whereClause,
whereArgs: args.isEmpty ? null : args,
orderBy:
filter?.sortNewestFirst == false ? 'timestamp ASC' : 'timestamp DESC',
limit: limit,
offset: offset,
);
return results.map((map) => LogEntry.fromMap(map)).toList();
}
/// Get unique logger names for filtering
Future<List<String>> getUniqueLoggers() async {
final db = await database;
final results = await db.rawQuery(
'SELECT DISTINCT logger_name FROM $_tableName ORDER BY logger_name',
);
return results.map((row) => row['logger_name'] as String).toList();
}
/// Get unique process prefixes for filtering
Future<List<String>> getUniqueProcesses() async {
final db = await database;
final results = await db.rawQuery(
'SELECT DISTINCT process_prefix FROM $_tableName WHERE process_prefix != "" ORDER BY process_prefix',
);
final prefixes =
results.map((row) => row['process_prefix'] as String).toList();
// Always include 'Foreground' as an option for empty prefix
final uniquePrefixes = <String>[''];
uniquePrefixes.addAll(prefixes);
return uniquePrefixes;
}
/// Get count of logs matching filter
Future<int> getLogCount({LogFilter? filter}) async {
final db = await database;
if (filter == null || !filter.hasActiveFilters) {
final result =
await db.rawQuery('SELECT COUNT(*) as count FROM $_tableName');
return result.first['count'] as int;
}
// Build WHERE clause (same as getLogs)
final conditions = <String>[];
final args = <dynamic>[];
if (filter.selectedLoggers.isNotEmpty) {
final placeholders =
List.filled(filter.selectedLoggers.length, '?').join(',');
conditions.add('logger_name IN ($placeholders)');
args.addAll(filter.selectedLoggers);
}
if (filter.selectedLevels.isNotEmpty) {
final placeholders =
List.filled(filter.selectedLevels.length, '?').join(',');
conditions.add('level IN ($placeholders)');
args.addAll(filter.selectedLevels);
}
if (filter.selectedProcesses.isNotEmpty) {
final placeholders =
List.filled(filter.selectedProcesses.length, '?').join(',');
conditions.add('process_prefix IN ($placeholders)');
args.addAll(filter.selectedProcesses);
}
if (filter.searchQuery != null && filter.searchQuery!.isNotEmpty) {
conditions.add('(message LIKE ? OR error LIKE ?)');
final searchPattern = '%${filter.searchQuery}%';
args.add(searchPattern);
args.add(searchPattern);
}
if (filter.startTime != null) {
conditions.add('timestamp >= ?');
args.add(filter.startTime!.millisecondsSinceEpoch);
}
if (filter.endTime != null) {
conditions.add('timestamp <= ?');
args.add(filter.endTime!.millisecondsSinceEpoch);
}
final whereClause = conditions.join(' AND ');
final query =
'SELECT COUNT(*) as count FROM $_tableName WHERE $whereClause';
final result = await db.rawQuery(query, args);
return result.first['count'] as int;
}
/// Clear all logs
Future<void> clearLogs() async {
final db = await database;
await db.delete(_tableName);
}
/// Clear logs by logger name
Future<void> clearLogsByLogger(String loggerName) async {
final db = await database;
await db.delete(
_tableName,
where: 'logger_name = ?',
whereArgs: [loggerName],
);
}
/// Truncate old logs if over limit
Future<void> _truncateIfNeeded(Database db) async {
final countResult = await db.rawQuery(
'SELECT COUNT(*) as count FROM $_tableName',
);
final count = countResult.first['count'] as int;
// When we reach 11k+ entries, keep only the last 10k
if (count >= maxEntries + 1000) {
final toDelete = count - maxEntries;
// Delete oldest entries
await db.execute('''
DELETE FROM $_tableName
WHERE id IN (
SELECT id FROM $_tableName
ORDER BY timestamp ASC
LIMIT ?
)
''', [toDelete],);
}
}
/// Get logger statistics with count and percentage
Future<List<LoggerStatistic>> getLoggerStatistics({LogFilter? filter}) async {
final db = await database;
// Build WHERE clause (same as getLogs)
final conditions = <String>[];
final args = <dynamic>[];
if (filter != null) {
if (filter.selectedLoggers.isNotEmpty) {
final placeholders =
List.filled(filter.selectedLoggers.length, '?').join(',');
conditions.add('logger_name IN ($placeholders)');
args.addAll(filter.selectedLoggers);
}
if (filter.selectedLevels.isNotEmpty) {
final placeholders =
List.filled(filter.selectedLevels.length, '?').join(',');
conditions.add('level IN ($placeholders)');
args.addAll(filter.selectedLevels);
}
if (filter.selectedProcesses.isNotEmpty) {
final placeholders =
List.filled(filter.selectedProcesses.length, '?').join(',');
conditions.add('process_prefix IN ($placeholders)');
args.addAll(filter.selectedProcesses);
}
if (filter.searchQuery != null && filter.searchQuery!.isNotEmpty) {
conditions.add('(message LIKE ? OR error LIKE ?)');
final searchPattern = '%${filter.searchQuery}%';
args.add(searchPattern);
args.add(searchPattern);
}
if (filter.startTime != null) {
conditions.add('timestamp >= ?');
args.add(filter.startTime!.millisecondsSinceEpoch);
}
if (filter.endTime != null) {
conditions.add('timestamp <= ?');
args.add(filter.endTime!.millisecondsSinceEpoch);
}
}
final whereClause =
conditions.isEmpty ? '' : 'WHERE ${conditions.join(' AND ')}';
// Get total count for percentage calculation
final totalQuery = 'SELECT COUNT(*) as total FROM $_tableName $whereClause';
final totalResult = await db.rawQuery(totalQuery, args);
final totalCount = totalResult.first['total'] as int;
if (totalCount == 0) return [];
// Get logger statistics using single optimized query
final statsQuery = '''
SELECT
logger_name,
COUNT(*) as count,
(COUNT(*) * 100.0 / $totalCount) as percentage
FROM $_tableName
$whereClause
GROUP BY logger_name
ORDER BY count DESC
''';
final results = await db.rawQuery(statsQuery, args);
return results
.map((row) => LoggerStatistic(
loggerName: row['logger_name'] as String,
logCount: row['count'] as int,
percentage: row['percentage'] as double,
),)
.toList();
}
/// Get time range of all logs
Future<TimeRange?> getTimeRange() async {
final db = await database;
final result = await db.rawQuery('''
SELECT
MIN(timestamp) as min_timestamp,
MAX(timestamp) as max_timestamp
FROM $_tableName
''');
if (result.isNotEmpty && result.first['min_timestamp'] != null) {
return TimeRange(
start: DateTime.fromMillisecondsSinceEpoch(result.first['min_timestamp'] as int),
end: DateTime.fromMillisecondsSinceEpoch(result.first['max_timestamp'] as int),
);
}
return null;
}
/// Get all log timestamps for timeline visualization
Future<List<DateTime>> getLogTimestamps() async {
final db = await database;
final result = await db.rawQuery('''
SELECT timestamp
FROM $_tableName
ORDER BY timestamp ASC
''');
return result
.map((row) => DateTime.fromMillisecondsSinceEpoch(row['timestamp'] as int))
.toList();
}
/// Close database connection
Future<void> close() async {
final db = _database;
if (db != null) {
await db.close();
_database = null;
}
}
}

View File

@@ -0,0 +1,255 @@
import 'package:flutter/material.dart';
/// Represents a single log entry
class LogEntry {
final int? id;
final String message;
final String level;
final DateTime timestamp;
final String loggerName;
final String? error;
final String? stackTrace;
final String processPrefix;
LogEntry({
this.id,
required this.message,
required this.level,
required this.timestamp,
required this.loggerName,
this.error,
this.stackTrace,
this.processPrefix = '',
});
/// Create from database map
factory LogEntry.fromMap(Map<String, dynamic> map) {
return LogEntry(
id: map['id'] as int?,
message: map['message'] as String,
level: map['level'] as String,
timestamp: DateTime.fromMillisecondsSinceEpoch(map['timestamp'] as int),
loggerName: map['logger_name'] as String,
error: map['error'] as String?,
stackTrace: map['stack_trace'] as String?,
processPrefix: map['process_prefix'] as String? ?? '',
);
}
/// Convert to database map
Map<String, dynamic> toMap() {
return {
if (id != null) 'id': id,
'message': message,
'level': level,
'timestamp': timestamp.millisecondsSinceEpoch,
'logger_name': loggerName,
'error': error,
'stack_trace': stackTrace,
'process_prefix': processPrefix,
};
}
/// Get color based on log level
Color get levelColor {
switch (level.toUpperCase()) {
case 'SHOUT':
case 'SEVERE':
return Colors.red;
case 'WARNING':
return Colors.orange;
case 'INFO':
return Colors.blue;
case 'CONFIG':
return Colors.green;
case 'FINE':
case 'FINER':
case 'FINEST':
return Colors.grey;
default:
return Colors.black54;
}
}
/// Get background color for list tile
Color? get backgroundColor {
switch (level.toUpperCase()) {
case 'SHOUT':
case 'SEVERE':
return Colors.red.withValues(alpha: 0.1);
case 'WARNING':
return Colors.orange.withValues(alpha: 0.1);
default:
return null;
}
}
/// Truncate message for preview
String get truncatedMessage {
final lines = message.split('\n');
const maxLines = 4;
if (lines.length <= maxLines) {
return message;
}
return '${lines.take(maxLines).join('\n')}...';
}
/// Format timestamp for display
String get formattedTime {
final hour = timestamp.hour.toString().padLeft(2, '0');
final minute = timestamp.minute.toString().padLeft(2, '0');
final second = timestamp.second.toString().padLeft(2, '0');
final millis = timestamp.millisecond.toString().padLeft(3, '0');
return '$hour:$minute:$second.$millis';
}
/// Get display name for process prefix
String get processDisplayName {
if (processPrefix.isEmpty) {
return 'Foreground';
}
// Remove square brackets if present (e.g., "[fbg]" -> "fbg")
final cleanPrefix = processPrefix.replaceAll(RegExp(r'[\[\]]'), '');
switch (cleanPrefix) {
case 'fbg':
return 'Firebase Background';
default:
return cleanPrefix.isEmpty ? 'Foreground' : cleanPrefix;
}
}
@override
String toString() {
final buffer = StringBuffer();
buffer.writeln('[$formattedTime] [$loggerName] [$level]');
buffer.writeln(message);
if (error != null) {
buffer.writeln('Error: $error');
}
if (stackTrace != null) {
buffer.writeln('Stack trace:\n$stackTrace');
}
return buffer.toString();
}
}
/// Filter configuration for log queries
class LogFilter {
final Set<String> selectedLoggers;
final Set<String> selectedLevels;
final Set<String> selectedProcesses;
final String? searchQuery;
final DateTime? startTime;
final DateTime? endTime;
final bool sortNewestFirst;
const LogFilter({
this.selectedLoggers = const {},
this.selectedLevels = const {},
this.selectedProcesses = const {},
this.searchQuery,
this.startTime,
this.endTime,
this.sortNewestFirst = true,
});
/// Create a copy with modifications
LogFilter copyWith({
Set<String>? selectedLoggers,
Set<String>? selectedLevels,
Set<String>? selectedProcesses,
String? searchQuery,
DateTime? startTime,
DateTime? endTime,
bool? sortNewestFirst,
bool clearSearchQuery = false,
bool clearTimeFilter = false,
}) {
return LogFilter(
selectedLoggers: selectedLoggers ?? this.selectedLoggers,
selectedLevels: selectedLevels ?? this.selectedLevels,
selectedProcesses: selectedProcesses ?? this.selectedProcesses,
searchQuery: clearSearchQuery ? null : (searchQuery ?? this.searchQuery),
startTime: clearTimeFilter ? null : (startTime ?? this.startTime),
endTime: clearTimeFilter ? null : (endTime ?? this.endTime),
sortNewestFirst: sortNewestFirst ?? this.sortNewestFirst,
);
}
/// Check if any filters are active
bool get hasActiveFilters {
return selectedLoggers.isNotEmpty ||
selectedLevels.isNotEmpty ||
selectedProcesses.isNotEmpty ||
(searchQuery != null && searchQuery!.isNotEmpty) ||
startTime != null ||
endTime != null;
}
/// Clear all filters
static const LogFilter empty = LogFilter();
}
/// Logger statistics data
class LoggerStatistic {
final String loggerName;
final int logCount;
final double percentage;
const LoggerStatistic({
required this.loggerName,
required this.logCount,
required this.percentage,
});
/// Alias for logCount for compatibility
int get count => logCount;
/// Format percentage for display
String get formattedPercentage {
if (percentage >= 10) {
return '${percentage.toStringAsFixed(1)}%';
} else if (percentage >= 1) {
return '${percentage.toStringAsFixed(1)}%';
} else {
return '${percentage.toStringAsFixed(2)}%';
}
}
}
/// Available log levels
class LogLevels {
static const List<String> all = [
'ALL',
'FINEST',
'FINER',
'FINE',
'CONFIG',
'INFO',
'WARNING',
'SEVERE',
'SHOUT',
'OFF',
];
/// Get levels typically shown by default
static const List<String> defaultVisible = [
'INFO',
'WARNING',
'SEVERE',
'SHOUT',
];
}
/// Represents a time range for logs
class TimeRange {
final DateTime start;
final DateTime end;
const TimeRange({
required this.start,
required this.end,
});
}

View File

@@ -0,0 +1,225 @@
import 'dart:async';
import 'package:log_viewer/src/core/log_database.dart';
import 'package:log_viewer/src/core/log_models.dart';
import 'package:logging/logging.dart' as log;
/// Singleton store that receives and manages logs
class LogStore {
static final LogStore _instance = LogStore._internal();
static LogStore get instance => _instance;
LogStore._internal();
final LogDatabase _database = LogDatabase();
final _logStreamController = StreamController<LogEntry>.broadcast();
// Buffer for batch inserts - optimized for small entries
final List<LogEntry> _buffer = [];
Timer? _flushTimer;
static const int _bufferSize = 10;
static const int _maxBufferSize = 200; // Safety limit
bool _initialized = false;
bool get initialized => _initialized;
/// Stream of new log entries
Stream<LogEntry> get logStream => _logStreamController.stream;
/// Initialize the log store
Future<void> initialize() async {
if (_initialized) return;
await _database.database; // Initialize database
// Start periodic flush timer - less frequent for better batching
_flushTimer = Timer.periodic(
const Duration(seconds: 15),
(_) => _flush(),
);
_initialized = true;
}
/// Static method that super_logging.dart will call
static void addLogRecord(log.LogRecord record, [String? processPrefix]) {
if (_instance._initialized) {
_instance._addLog(record, processPrefix ?? '');
}
}
/// Add a log from a LogRecord
void _addLog(log.LogRecord record, String processPrefix) {
final entry = LogEntry(
message: record.message,
level: record.level.name,
timestamp: record.time,
loggerName: record.loggerName,
error: record.error?.toString(),
stackTrace: record.stackTrace?.toString(),
processPrefix: processPrefix,
);
// Add to buffer for batch insert
_buffer.add(entry);
// Emit to stream for real-time updates
_logStreamController.add(entry);
// Flush when buffer reaches optimal size or safety limit
if (_buffer.length >= _bufferSize) {
_flush();
} else if (_buffer.length >= _maxBufferSize) {
// Emergency flush if buffer grows too large
_flush();
}
}
/// Flush buffered logs to database
Future<void> _flush() async {
if (_buffer.isEmpty) return;
final toInsert = List<LogEntry>.from(_buffer);
_buffer.clear();
// Use non-blocking database insert for better write performance
unawaited(_database.insertLogs(toInsert).catchError((e) {
// ignore: avoid_print
print('Failed to insert logs to database: $e');
}),);
}
/// Get logs with filtering
Future<List<LogEntry>> getLogs({
LogFilter? filter,
int limit = 250,
int offset = 0,
}) async {
// Flush any pending logs first
await _flush();
return _database.getLogs(
filter: filter,
limit: limit,
offset: offset,
);
}
/// Get unique logger names
Future<List<String>> getLoggerNames() async {
return _database.getUniqueLoggers();
}
/// Get unique process prefixes
Future<List<String>> getProcessNames() async {
return _database.getUniqueProcesses();
}
/// Get logger statistics with count and percentage
Future<List<LoggerStatistic>> getLoggerStatistics({LogFilter? filter}) async {
await _flush();
return _database.getLoggerStatistics(filter: filter);
}
/// Get count of logs matching filter
Future<int> getLogCount({LogFilter? filter}) async {
await _flush();
return _database.getLogCount(filter: filter);
}
/// Clear all logs
Future<void> clearLogs() async {
_buffer.clear();
await _database.clearLogs();
}
/// Clear logs by logger
Future<void> clearLogsByLogger(String loggerName) async {
_buffer.removeWhere((log) => log.loggerName == loggerName);
await _database.clearLogsByLogger(loggerName);
}
/// Export logs as text
Future<String> exportLogs({LogFilter? filter}) async {
final logs = await getLogs(filter: filter, limit: 10000);
final buffer = StringBuffer();
buffer.writeln('=== Ente App Logs ===');
buffer.writeln('Exported at: ${DateTime.now()}');
if (filter != null && filter.hasActiveFilters) {
buffer.writeln('Filters applied:');
if (filter.selectedLoggers.isNotEmpty) {
buffer.writeln(' Loggers: ${filter.selectedLoggers.join(', ')}');
}
if (filter.selectedLevels.isNotEmpty) {
buffer.writeln(' Levels: ${filter.selectedLevels.join(', ')}');
}
if (filter.searchQuery != null && filter.searchQuery!.isNotEmpty) {
buffer.writeln(' Search: ${filter.searchQuery}');
}
}
buffer.writeln('Total logs: ${logs.length}');
buffer.writeln('=' * 40);
buffer.writeln();
for (final log in logs) {
buffer.writeln(log.toString());
buffer.writeln('-' * 40);
}
return buffer.toString();
}
/// Get time range of all logs
Future<TimeRange?> getTimeRange() async {
await _flush();
return _database.getTimeRange();
}
/// Get all log timestamps for timeline visualization
Future<List<DateTime>> getLogTimestamps() async {
await _flush();
return _database.getLogTimestamps();
}
/// Export logs as JSON
Future<String> exportLogsAsJson({LogFilter? filter}) async {
final logs = await getLogs(filter: filter, limit: 10000);
final jsonLogs = logs
.map((log) => {
'timestamp': log.timestamp.toIso8601String(),
'level': log.level,
'logger': log.loggerName,
'message': log.message,
if (log.error != null) 'error': log.error,
if (log.stackTrace != null) 'stackTrace': log.stackTrace,
},)
.toList();
// Manual JSON formatting for readability
final buffer = StringBuffer();
buffer.writeln('[');
for (int i = 0; i < jsonLogs.length; i++) {
buffer.write(' ');
buffer.write(jsonLogs[i].toString());
if (i < jsonLogs.length - 1) {
buffer.writeln(',');
} else {
buffer.writeln();
}
}
buffer.writeln(']');
return buffer.toString();
}
/// Dispose resources
Future<void> dispose() async {
_flushTimer?.cancel();
await _flush();
await _database.close();
await _logStreamController.close();
_initialized = false;
}
}

View File

@@ -0,0 +1,197 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:log_viewer/src/core/log_models.dart';
/// Detailed view of a single log entry
class LogDetailPage extends StatelessWidget {
final LogEntry log;
const LogDetailPage({
super.key,
required this.log,
});
void _copyToClipboard(BuildContext context, String text, String label) {
Clipboard.setData(ClipboardData(text: text));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('$label copied to clipboard'),
duration: const Duration(seconds: 2),
),
);
}
Widget _buildSection({
required BuildContext context,
required String title,
required String content,
bool isMonospace = true,
}) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
title,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
color: theme.primaryColor,
),
),
IconButton(
icon: const Icon(Icons.copy, size: 18),
onPressed: () => _copyToClipboard(context, content, title),
tooltip: 'Copy $title',
),
],
),
const SizedBox(height: 8),
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: theme.cardColor,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: theme.dividerColor,
width: 1,
),
),
child: SelectableText(
content,
style: TextStyle(
fontFamily: isMonospace ? 'monospace' : null,
fontSize: 13,
),
),
),
],
),
);
}
Widget _buildInfoRow({
required BuildContext context,
required IconData icon,
required String label,
required String value,
Color? valueColor,
}) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
Icon(icon, size: 20, color: theme.disabledColor),
const SizedBox(width: 12),
Text(
'$label: ',
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
),
Expanded(
child: Text(
value,
style: theme.textTheme.bodyMedium?.copyWith(
color: valueColor,
fontWeight: valueColor != null ? FontWeight.bold : null,
),
),
),
],
),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(
title: const Text('Log Details'),
elevation: 0,
actions: [
IconButton(
icon: const Icon(Icons.copy),
onPressed: () => _copyToClipboard(
context,
log.toString(),
'Complete log',
),
tooltip: 'Copy all',
),
],
),
body: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Log metadata
Container(
width: double.infinity,
color: theme.appBarTheme.backgroundColor,
padding: const EdgeInsets.symmetric(vertical: 8),
child: Column(
children: [
_buildInfoRow(
context: context,
icon: Icons.access_time,
label: 'Time',
value: '${log.timestamp.toLocal()}',
),
_buildInfoRow(
context: context,
icon: Icons.flag,
label: 'Level',
value: log.level,
valueColor: log.levelColor,
),
_buildInfoRow(
context: context,
icon: Icons.source,
label: 'Logger',
value: log.loggerName,
),
],
),
),
// Message section
_buildSection(
context: context,
title: 'MESSAGE',
content: log.message,
),
// Error section (if present)
if (log.error != null)
_buildSection(
context: context,
title: 'ERROR',
content: log.error!,
),
// Stack trace section (if present)
if (log.stackTrace != null)
_buildSection(
context: context,
title: 'STACK TRACE',
content: log.stackTrace!,
),
const SizedBox(height: 16),
],
),
),
);
}
}

View File

@@ -0,0 +1,294 @@
import 'package:flutter/material.dart';
import 'package:log_viewer/src/core/log_models.dart';
/// Dialog for configuring log filters
class LogFilterDialog extends StatefulWidget {
final List<String> availableLoggers;
final List<String> availableProcesses;
final LogFilter currentFilter;
const LogFilterDialog({
super.key,
required this.availableLoggers,
required this.availableProcesses,
required this.currentFilter,
});
@override
State<LogFilterDialog> createState() => _LogFilterDialogState();
}
class _LogFilterDialogState extends State<LogFilterDialog> {
late Set<String> _selectedLoggers;
late Set<String> _selectedLevels;
late Set<String> _selectedProcesses;
DateTime? _startTime;
DateTime? _endTime;
@override
void initState() {
super.initState();
_selectedLoggers = Set.from(widget.currentFilter.selectedLoggers);
_selectedLevels = Set.from(widget.currentFilter.selectedLevels);
_selectedProcesses = Set.from(widget.currentFilter.selectedProcesses);
_startTime = widget.currentFilter.startTime;
_endTime = widget.currentFilter.endTime;
}
void _applyFilters() {
final newFilter = LogFilter(
selectedLoggers: _selectedLoggers,
selectedLevels: _selectedLevels,
selectedProcesses: _selectedProcesses,
searchQuery: widget.currentFilter.searchQuery,
startTime: _startTime,
endTime: _endTime,
);
Navigator.pop(context, newFilter);
}
void _clearFilters() {
setState(() {
_selectedLoggers.clear();
_selectedLevels.clear();
_selectedProcesses.clear();
});
}
Widget _buildLevelChip(String level) {
final isSelected = _selectedLevels.contains(level);
final color = LogEntry(
message: '',
level: level,
timestamp: DateTime.now(),
loggerName: '',
).levelColor;
return FilterChip(
label: Text(
level,
style: TextStyle(
color: isSelected ? Colors.white : null,
fontSize: 12,
),
),
selected: isSelected,
selectedColor: color,
checkmarkColor: Colors.white,
onSelected: (selected) {
setState(() {
if (selected) {
_selectedLevels.add(level);
} else {
_selectedLevels.remove(level);
}
});
},
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Dialog(
child: Container(
constraints: const BoxConstraints(maxWidth: 400, maxHeight: 600),
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)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Filter Logs',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.pop(context),
),
],
),
),
// Content
Flexible(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Log Levels
Text(
'Log Levels',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: LogLevels.all
.where((level) => level != 'ALL' && level != 'OFF')
.map(_buildLevelChip)
.toList(),
),
const SizedBox(height: 24),
// 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
if (widget.availableProcesses.isNotEmpty) ...[
Text(
'Processes',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Container(
constraints: const BoxConstraints(maxHeight: 120),
decoration: BoxDecoration(
border: Border.all(color: theme.dividerColor),
borderRadius: BorderRadius.circular(4),
),
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,
),
)
: null,
value: _selectedProcesses.contains(process),
dense: true,
onChanged: (selected) {
setState(() {
if (selected == true) {
_selectedProcesses.add(process);
} else {
_selectedProcesses.remove(process);
}
});
},
);
},
),
),
const SizedBox(height: 24),
],
],
),
),
),
// Actions
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: theme.cardColor,
borderRadius:
const BorderRadius.vertical(bottom: Radius.circular(4)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
TextButton(
onPressed: _clearFilters,
child: const Text('Clear All'),
),
Row(
children: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
const SizedBox(width: 8),
ElevatedButton(
onPressed: _applyFilters,
child: const Text('Apply'),
),
],
),
],
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,95 @@
import 'package:flutter/material.dart';
import 'package:log_viewer/src/core/log_models.dart';
/// Individual log item widget
class LogListTile extends StatelessWidget {
final LogEntry log;
final VoidCallback onTap;
const LogListTile({
super.key,
required this.log,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return ListTile(
onTap: onTap,
tileColor: log.backgroundColor,
leading: Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: log.levelColor,
shape: BoxShape.circle,
),
),
title: Text(
log.truncatedMessage,
style: TextStyle(
fontFamily: 'monospace',
fontSize: 13,
color: theme.textTheme.bodyLarge?.color,
),
maxLines: 4,
overflow: TextOverflow.ellipsis,
),
subtitle: Padding(
padding: const EdgeInsets.only(top: 4),
child: Row(
children: [
Icon(
Icons.access_time,
size: 12,
color: theme.textTheme.bodySmall?.color,
),
const SizedBox(width: 4),
Text(
log.formattedTime,
style: TextStyle(
fontSize: 11,
color: theme.textTheme.bodySmall?.color,
),
),
const SizedBox(width: 12),
Icon(
Icons.source,
size: 12,
color: theme.textTheme.bodySmall?.color,
),
const SizedBox(width: 4),
Flexible(
child: Text(
log.loggerName,
style: TextStyle(
fontSize: 11,
color: theme.textTheme.bodySmall?.color,
fontWeight: FontWeight.w500,
),
overflow: TextOverflow.ellipsis,
),
),
if (log.error != null) ...[
const SizedBox(width: 8),
Icon(
Icons.error_outline,
size: 14,
color: Colors.red[400],
),
],
],
),
),
trailing: Icon(
Icons.chevron_right,
size: 20,
color: theme.disabledColor,
),
visualDensity: VisualDensity.compact,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
);
}
}

View File

@@ -0,0 +1,639 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:log_viewer/src/core/log_models.dart';
import 'package:log_viewer/src/core/log_store.dart';
import 'package:log_viewer/src/ui/log_detail_page.dart';
import 'package:log_viewer/src/ui/log_filter_dialog.dart';
import 'package:log_viewer/src/ui/log_list_tile.dart';
import 'package:log_viewer/src/ui/logger_statistics_page.dart';
import 'package:log_viewer/src/ui/timeline_widget.dart';
import 'package:share_plus/share_plus.dart';
/// Main log viewer page
class LogViewerPage extends StatefulWidget {
const LogViewerPage({super.key});
@override
State<LogViewerPage> createState() => _LogViewerPageState();
}
class _LogViewerPageState extends State<LogViewerPage> {
final LogStore _logStore = LogStore.instance;
final TextEditingController _searchController = TextEditingController();
List<LogEntry> _logs = [];
List<String> _availableLoggers = [];
List<String> _availableProcesses = [];
LogFilter _filter = const LogFilter();
bool _isLoading = true;
bool _isLoadingMore = false;
bool _hasMoreLogs = true;
int _currentOffset = 0;
static const int _pageSize = 100; // Load 100 logs at a time
StreamSubscription<LogEntry>? _logStreamSubscription;
// Time filtering state
bool _timeFilterEnabled = false;
// Timeline state
DateTime? _overallStartTime;
DateTime? _overallEndTime;
DateTime? _timelineStartTime;
DateTime? _timelineEndTime;
List<DateTime> _logTimestamps = [];
@override
void initState() {
super.initState();
_initialize();
}
Future<void> _initialize() async {
await _loadLoggers();
await _loadProcesses();
await _initializeTimeline();
await _loadLogs();
// Listen for new logs
_logStreamSubscription = _logStore.logStream.listen((_) {
// Debounce updates to avoid too frequent refreshes
_scheduleRefresh();
});
}
Future<void> _initializeTimeline() async {
final timeRange = await _logStore.getTimeRange();
if (timeRange != null) {
setState(() {
_overallStartTime = timeRange.start;
_overallEndTime = timeRange.end;
_timelineStartTime = timeRange.start;
_timelineEndTime = timeRange.end;
});
}
await _loadLogTimestamps();
}
Future<void> _loadLogTimestamps() async {
final timestamps = await _logStore.getLogTimestamps();
setState(() {
_logTimestamps = timestamps;
});
}
void _onTimelineRangeChanged(DateTime start, DateTime end) {
setState(() {
_timelineStartTime = start;
_timelineEndTime = end;
_filter = _filter.copyWith(
startTime: start,
endTime: end,
);
});
_loadLogs();
}
Timer? _refreshTimer;
void _scheduleRefresh() {
_refreshTimer?.cancel();
_refreshTimer = Timer(const Duration(seconds: 1), () {
if (mounted) {
_loadLogs();
}
});
}
Future<void> _loadLogs({bool reset = true}) async {
if (reset) {
setState(() {
_isLoading = true;
_currentOffset = 0;
_hasMoreLogs = true;
_logs.clear();
});
} else {
setState(() => _isLoadingMore = true);
}
try {
final logs = await _logStore.getLogs(
filter: _filter,
limit: _pageSize,
offset: _currentOffset,
);
if (mounted) {
setState(() {
if (reset) {
_logs = logs;
_isLoading = false;
} else {
_logs.addAll(logs);
_isLoadingMore = false;
}
_currentOffset += logs.length;
_hasMoreLogs = logs.length == _pageSize;
});
}
} catch (e) {
if (mounted) {
setState(() {
_isLoading = false;
_isLoadingMore = false;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to load logs: $e')),
);
}
}
}
Future<void> _loadMoreLogs() async {
if (!_hasMoreLogs || _isLoadingMore) return;
await _loadLogs(reset: false);
}
Future<void> _loadLoggers() async {
try {
final loggers = await _logStore.getLoggerNames();
if (mounted) {
setState(() => _availableLoggers = loggers);
}
} catch (e) {
debugPrint('Failed to load logger names: $e');
}
}
Future<void> _loadProcesses() async {
try {
final processes = await _logStore.getProcessNames();
if (mounted) {
setState(() => _availableProcesses = processes);
}
} catch (e) {
debugPrint('Failed to load process names: $e');
}
}
void _onSearchChanged(String query) {
setState(() {
_filter = _filter.copyWith(
searchQuery: query.isEmpty ? null : query,
clearSearchQuery: query.isEmpty,
);
});
_loadLogs();
}
void _updateTimeFilter() {
setState(() {
if (_timeFilterEnabled && _timelineStartTime != null && _timelineEndTime != null) {
_filter = _filter.copyWith(
startTime: _timelineStartTime,
endTime: _timelineEndTime,
);
} else {
_filter = _filter.copyWith(
clearTimeFilter: true,
);
}
});
_loadLogs();
}
// String _formatTimeRange(double hours) {
// if (hours < 1) {
// final minutes = (hours * 60).round();
// return '${minutes}m';
// } else if (hours < 24) {
// return '${hours.round()}h';
// } else {
// final days = (hours / 24).round();
// return '${days}d';
// }
// }
Future<void> _showFilterDialog() async {
final newFilter = await showDialog<LogFilter>(
context: context,
builder: (context) => LogFilterDialog(
availableLoggers: _availableLoggers,
availableProcesses: _availableProcesses,
currentFilter: _filter,
),
);
if (newFilter != null && mounted) {
setState(() => _filter = newFilter);
await _loadLogs();
}
}
Future<void> _clearLogs() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Clear Logs'),
content: const Text('Are you sure you want to clear all logs?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Clear', style: TextStyle(color: Colors.red)),
),
],
),
);
if (confirmed == true) {
await _logStore.clearLogs();
await _loadLogs();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Logs cleared')),
);
}
}
}
Future<void> _exportLogs() async {
try {
final logText = await _logStore.exportLogs(filter: _filter);
await Share.share(logText, subject: 'App Logs');
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to export logs: $e')),
);
}
}
}
void _toggleSort() {
setState(() {
_filter = _filter.copyWith(
sortNewestFirst: !_filter.sortNewestFirst,
);
});
_loadLogs();
}
void _showAnalytics() {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => LoggerStatisticsPage(filter: _filter),
),
);
}
void _showLogDetail(LogEntry log) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => LogDetailPage(log: log),
),
);
}
@override
void dispose() {
_searchController.dispose();
_logStreamSubscription?.cancel();
_refreshTimer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(
title: const Text('Logs'),
elevation: 0,
actions: [
if (_filter.hasActiveFilters)
IconButton(
icon: Stack(
children: [
const Icon(Icons.filter_list),
Positioned(
right: 0,
top: 0,
child: Container(
width: 8,
height: 8,
decoration: const BoxDecoration(
color: Colors.red,
shape: BoxShape.circle,
),
),
),
],
),
onPressed: _showFilterDialog,
tooltip: 'Filters',
)
else
IconButton(
icon: const Icon(Icons.filter_list),
onPressed: _showFilterDialog,
tooltip: 'Filters',
),
IconButton(
icon: Icon(_filter.sortNewestFirst
? Icons.arrow_downward
: Icons.arrow_upward,),
onPressed: _toggleSort,
tooltip: _filter.sortNewestFirst
? 'Sort oldest first'
: 'Sort newest first',
),
PopupMenuButton<String>(
onSelected: (value) {
switch (value) {
case 'analytics':
_showAnalytics();
break;
case 'clear':
_clearLogs();
break;
case 'export':
_exportLogs();
break;
}
},
itemBuilder: (context) => [
const PopupMenuItem(
value: 'analytics',
child: ListTile(
leading: Icon(Icons.analytics),
title: Text('View Analytics'),
contentPadding: EdgeInsets.zero,
),
),
const PopupMenuItem(
value: 'export',
child: ListTile(
leading: Icon(Icons.share),
title: Text('Export Logs'),
contentPadding: EdgeInsets.zero,
),
),
const PopupMenuItem(
value: 'clear',
child: ListTile(
leading: Icon(Icons.clear_all, color: Colors.red),
title:
Text('Clear Logs', style: TextStyle(color: Colors.red)),
contentPadding: EdgeInsets.zero,
),
),
],
),
],
),
body: Column(
children: [
// Search bar
Container(
color: theme.appBarTheme.backgroundColor,
padding: const EdgeInsets.all(8.0),
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Search logs...',
prefixIcon: const Icon(Icons.search),
suffixIcon: _searchController.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
_onSearchChanged('');
},
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
),
onChanged: _onSearchChanged,
),
),
// Timeline filter
if (_overallStartTime != null && _overallEndTime != null) ...[
Container(
color: theme.appBarTheme.backgroundColor,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
Icon(
Icons.timeline,
size: 18,
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
),
const SizedBox(width: 8),
Text(
'Timeline Filter',
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
),
const Spacer(),
IconButton(
icon: Icon(
_timeFilterEnabled
? Icons.timeline
: Icons.timeline_outlined,
color: _timeFilterEnabled
? theme.colorScheme.primary
: theme.colorScheme.onSurface,
),
onPressed: () {
setState(() {
_timeFilterEnabled = !_timeFilterEnabled;
if (_timeFilterEnabled) {
// Reset timeline to full range when enabled
_timelineStartTime = _overallStartTime;
_timelineEndTime = _overallEndTime;
}
});
_updateTimeFilter();
},
tooltip: _timeFilterEnabled ? 'Disable Timeline Filter' : 'Enable Timeline Filter',
),
],
),
),
if (_timeFilterEnabled) ...[
TimelineWidget(
startTime: _overallStartTime!,
endTime: _overallEndTime!,
currentStart: _timelineStartTime ?? _overallStartTime!,
currentEnd: _timelineEndTime ?? _overallEndTime!,
onTimeRangeChanged: _onTimelineRangeChanged,
logTimestamps: _logTimestamps,
),
],
],
// Active filters display
if (_filter.hasActiveFilters)
Container(
height: 40,
padding: const EdgeInsets.symmetric(horizontal: 8),
child: ListView(
scrollDirection: Axis.horizontal,
children: [
if (_filter.selectedLoggers.isNotEmpty)
..._filter.selectedLoggers.map((logger) => Padding(
padding: const EdgeInsets.only(right: 4),
child: Chip(
label: Text(logger,
style: const TextStyle(fontSize: 12),),
deleteIcon: const Icon(Icons.close, size: 16),
onDeleted: () {
setState(() {
final newLoggers =
Set<String>.from(_filter.selectedLoggers);
newLoggers.remove(logger);
_filter = _filter.copyWith(
selectedLoggers: newLoggers,);
});
_loadLogs();
},
),
),),
if (_filter.selectedLevels.isNotEmpty)
..._filter.selectedLevels.map((level) => Padding(
padding: const EdgeInsets.only(right: 4),
child: Chip(
label: Text(level,
style: const TextStyle(fontSize: 12),),
backgroundColor: LogEntry(
message: '',
level: level,
timestamp: DateTime.now(),
loggerName: '',
).levelColor.withValues(alpha: 0.2),
deleteIcon: const Icon(Icons.close, size: 16),
onDeleted: () {
setState(() {
final newLevels =
Set<String>.from(_filter.selectedLevels);
newLevels.remove(level);
_filter =
_filter.copyWith(selectedLevels: newLevels);
});
_loadLogs();
},
),
),),
if (_filter.selectedProcesses.isNotEmpty)
..._filter.selectedProcesses.map((process) => Padding(
padding: const EdgeInsets.only(right: 4),
child: Chip(
label: Text(
LogEntry(
message: '',
level: 'INFO',
timestamp: DateTime.now(),
loggerName: '',
processPrefix: process,
).processDisplayName,
style: const TextStyle(fontSize: 12),),
backgroundColor: Colors.purple.withValues(alpha: 0.2),
deleteIcon: const Icon(Icons.close, size: 16),
onDeleted: () {
setState(() {
final newProcesses =
Set<String>.from(_filter.selectedProcesses);
newProcesses.remove(process);
_filter =
_filter.copyWith(selectedProcesses: newProcesses);
});
_loadLogs();
},
),
),),
],
),
),
// Log list
Expanded(
child: _isLoading
? const Center(child: CircularProgressIndicator())
: _logs.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.inbox,
size: 64,
color: theme.disabledColor,
),
const SizedBox(height: 16),
Text(
_filter.hasActiveFilters
? 'No logs match the current filters'
: 'No logs available',
style: theme.textTheme.bodyLarge?.copyWith(
color: theme.disabledColor,
),
),
],
),
)
: RefreshIndicator(
onRefresh: _loadLogs,
child: ListView.separated(
itemCount: _logs.length + (_hasMoreLogs ? 1 : 0),
separatorBuilder: (context, index) =>
index >= _logs.length
? const SizedBox.shrink()
: const Divider(height: 1),
itemBuilder: (context, index) {
// Show loading indicator at the bottom
if (index >= _logs.length) {
if (_isLoadingMore) {
return const Padding(
padding: EdgeInsets.all(16),
child: Center(
child: CircularProgressIndicator(),),
);
} else {
// Trigger loading more when reaching the end
WidgetsBinding.instance
.addPostFrameCallback((_) {
_loadMoreLogs();
});
return const SizedBox.shrink();
}
}
final log = _logs[index];
return LogListTile(
log: log,
onTap: () => _showLogDetail(log),
);
},
),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,300 @@
import 'package:flutter/material.dart';
import 'package:log_viewer/src/core/log_models.dart';
import 'package:log_viewer/src/core/log_store.dart';
/// Page showing logger statistics with percentage breakdown
class LoggerStatisticsPage extends StatefulWidget {
final LogFilter filter;
const LoggerStatisticsPage({
super.key,
required this.filter,
});
@override
State<LoggerStatisticsPage> createState() => _LoggerStatisticsPageState();
}
class _LoggerStatisticsPageState extends State<LoggerStatisticsPage> {
final LogStore _logStore = LogStore.instance;
List<LoggerStatistic> _statistics = [];
bool _isLoading = true;
String? _error;
@override
void initState() {
super.initState();
_loadStatistics();
}
Future<void> _loadStatistics() async {
setState(() {
_isLoading = true;
_error = null;
});
try {
final stats = await _logStore.getLoggerStatistics(filter: widget.filter);
if (mounted) {
setState(() {
_statistics = stats;
_isLoading = false;
});
}
} catch (e) {
if (mounted) {
setState(() {
_error = e.toString();
_isLoading = false;
});
}
}
}
Color _getLoggerColor(int index, double percentage) {
// Color coding based on percentage
if (percentage > 50) return Colors.red.shade400;
if (percentage > 20) return Colors.orange.shade400;
if (percentage > 10) return Colors.blue.shade400;
return Colors.green.shade400;
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(
title: const Text('Logger Analytics'),
elevation: 0,
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: _error != null
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 48,
color: theme.colorScheme.error,
),
const SizedBox(height: 16),
Text(
'Failed to load statistics',
style: theme.textTheme.headlineSmall,
),
const SizedBox(height: 8),
Text(
_error!,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.error,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: _loadStatistics,
icon: const Icon(Icons.refresh),
label: const Text('Retry'),
),
],
),
)
: _statistics.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.analytics_outlined,
size: 64,
color: theme.disabledColor,
),
const SizedBox(height: 16),
Text(
'No log data available',
style: theme.textTheme.headlineSmall?.copyWith(
color: theme.disabledColor,
),
),
],
),
)
: Column(
children: [
// Summary cards
Container(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Expanded(
child: _SummaryCard(
title: 'Total Logs',
value: _statistics
.fold(0, (sum, stat) => sum + stat.count)
.toString(),
icon: Icons.notes,
color: Colors.blue,
),
),
const SizedBox(width: 16),
Expanded(
child: _SummaryCard(
title: 'Loggers',
value: _statistics.length.toString(),
icon: Icons.category,
color: Colors.green,
),
),
],
),
),
// Statistics list
Expanded(
child: RefreshIndicator(
onRefresh: _loadStatistics,
child: ListView.builder(
padding:
const EdgeInsets.symmetric(horizontal: 16),
itemCount: _statistics.length,
itemBuilder: (context, index) {
final stat = _statistics[index];
final color =
_getLoggerColor(index, stat.percentage);
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
stat.loggerName,
style: theme
.textTheme.titleMedium
?.copyWith(
fontWeight: FontWeight.w500,
),
overflow: TextOverflow.ellipsis,
),
),
Text(
stat.formattedPercentage,
style: theme.textTheme.titleMedium
?.copyWith(
color: color,
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: LinearProgressIndicator(
value: stat.percentage / 100,
backgroundColor:
color.withValues(alpha: 0.2),
valueColor:
AlwaysStoppedAnimation(
color,),
minHeight: 6,
),
),
const SizedBox(width: 12),
Text(
'${stat.count} logs',
style: theme.textTheme.bodyMedium
?.copyWith(
color: theme.colorScheme
.onSurfaceVariant,
fontWeight: FontWeight.w500,
),
),
],
),
],
),
),
);
},
),
),
),
],
),
);
}
}
class _SummaryCard extends StatelessWidget {
final String title;
final String value;
final IconData icon;
final Color color;
const _SummaryCard({
required this.title,
required this.value,
required this.icon,
required this.color,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: color.withValues(alpha: 0.3),
width: 1,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
icon,
color: color,
size: 20,
),
const SizedBox(width: 8),
Text(
title,
style: theme.textTheme.bodyMedium?.copyWith(
color: color,
fontWeight: FontWeight.w500,
),
),
],
),
const SizedBox(height: 8),
Text(
value,
style: theme.textTheme.headlineMedium?.copyWith(
color: color,
fontWeight: FontWeight.bold,
),
),
],
),
);
}
}

View File

@@ -0,0 +1,272 @@
import 'package:flutter/material.dart';
class TimelineWidget extends StatefulWidget {
final DateTime startTime;
final DateTime endTime;
final DateTime currentStart;
final DateTime currentEnd;
final Function(DateTime start, DateTime end) onTimeRangeChanged;
final List<DateTime> logTimestamps;
const TimelineWidget({
super.key,
required this.startTime,
required this.endTime,
required this.currentStart,
required this.currentEnd,
required this.onTimeRangeChanged,
this.logTimestamps = const [],
});
@override
State<TimelineWidget> createState() => _TimelineWidgetState();
}
class _TimelineWidgetState extends State<TimelineWidget> {
late double _leftPosition;
late double _rightPosition;
bool _isDraggingLeft = false;
bool _isDraggingRight = false;
@override
void initState() {
super.initState();
_updatePositions();
}
@override
void didUpdateWidget(TimelineWidget oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.currentStart != widget.currentStart ||
oldWidget.currentEnd != widget.currentEnd ||
oldWidget.startTime != widget.startTime ||
oldWidget.endTime != widget.endTime) {
_updatePositions();
}
}
void _updatePositions() {
final totalDuration = widget.endTime.difference(widget.startTime).inMilliseconds;
final startOffset = widget.currentStart.difference(widget.startTime).inMilliseconds;
final endOffset = widget.currentEnd.difference(widget.startTime).inMilliseconds;
_leftPosition = startOffset / totalDuration;
_rightPosition = endOffset / totalDuration;
}
void _onPanUpdate(DragUpdateDetails details, bool isLeft) {
final RenderBox renderBox = context.findRenderObject() as RenderBox;
final double width = renderBox.size.width - 40; // Account for handle width
// Convert global position to local position within the timeline track
final Offset globalPosition = details.globalPosition;
final Offset localPosition = renderBox.globalToLocal(globalPosition);
final double localX = localPosition.dx - 20; // Account for left handle width
final double position = (localX / width).clamp(0.0, 1.0);
setState(() {
if (isLeft) {
_leftPosition = position.clamp(0.0, _rightPosition - 0.01);
} else {
_rightPosition = position.clamp(_leftPosition + 0.01, 1.0);
}
});
final totalDuration = widget.endTime.difference(widget.startTime).inMilliseconds;
final newStart = widget.startTime.add(Duration(milliseconds: (_leftPosition * totalDuration).round()));
final newEnd = widget.startTime.add(Duration(milliseconds: (_rightPosition * totalDuration).round()));
widget.onTimeRangeChanged(newStart, newEnd);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
height: 120,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Timeline Filter',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
return Stack(
children: [
// Timeline track
Positioned(
left: 20,
right: 20,
top: 20,
bottom: 20,
child: Container(
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(4),
border: Border.all(
color: theme.colorScheme.outline.withValues(alpha: 0.3),
width: 1,
),
),
child: _buildLogDensityIndicator(constraints.maxWidth - 40),
),
),
// Selected range
Positioned(
left: 20 + (_leftPosition * (constraints.maxWidth - 40)),
right: constraints.maxWidth - 20 - (_rightPosition * (constraints.maxWidth - 40)),
top: 20,
bottom: 20,
child: Container(
decoration: BoxDecoration(
color: theme.colorScheme.primary.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(4),
border: Border.all(
color: theme.colorScheme.primary.withValues(alpha: 0.7),
width: 1,
),
),
),
),
// Left handle
Positioned(
left: (_leftPosition * (constraints.maxWidth - 40)),
top: 12,
child: GestureDetector(
onPanUpdate: (details) => _onPanUpdate(details, true),
onPanStart: (_) => setState(() => _isDraggingLeft = true),
onPanEnd: (_) => setState(() => _isDraggingLeft = false),
child: Container(
width: 20,
height: 32,
decoration: BoxDecoration(
color: _isDraggingLeft
? theme.colorScheme.primary
: theme.colorScheme.primary.withValues(alpha: 0.8),
borderRadius: BorderRadius.circular(4),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.2),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Icon(
Icons.drag_indicator,
size: 12,
color: theme.colorScheme.onPrimary,
),
),
),
),
// Right handle
Positioned(
left: (_rightPosition * (constraints.maxWidth - 40)),
top: 12,
child: GestureDetector(
onPanUpdate: (details) => _onPanUpdate(details, false),
onPanStart: (_) => setState(() => _isDraggingRight = true),
onPanEnd: (_) => setState(() => _isDraggingRight = false),
child: Container(
width: 20,
height: 32,
decoration: BoxDecoration(
color: _isDraggingRight
? theme.colorScheme.primary
: theme.colorScheme.primary.withValues(alpha: 0.8),
borderRadius: BorderRadius.circular(4),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.2),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Icon(
Icons.drag_indicator,
size: 12,
color: theme.colorScheme.onPrimary,
),
),
),
),
],
);
},
),
),
// Time labels
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
_formatTime(widget.currentStart),
style: theme.textTheme.bodySmall,
),
Text(
_formatTime(widget.currentEnd),
style: theme.textTheme.bodySmall,
),
],
),
],
),
);
}
Widget _buildLogDensityIndicator(double width) {
if (widget.logTimestamps.isEmpty) return const SizedBox.shrink();
final theme = Theme.of(context);
final totalDuration = widget.endTime.difference(widget.startTime).inMilliseconds;
const bucketCount = 50;
final bucketDuration = totalDuration / bucketCount;
final buckets = List<int>.filled(bucketCount, 0);
// Count logs in each bucket
for (final timestamp in widget.logTimestamps) {
final offset = timestamp.difference(widget.startTime).inMilliseconds;
if (offset >= 0 && offset <= totalDuration) {
final bucketIndex = (offset / bucketDuration).floor().clamp(0, bucketCount - 1);
buckets[bucketIndex]++;
}
}
final maxCount = buckets.reduce((a, b) => a > b ? a : b);
if (maxCount == 0) return const SizedBox.shrink();
return Row(
children: buckets.map((count) {
final intensity = count / maxCount;
return Expanded(
child: Container(
height: double.infinity,
margin: const EdgeInsets.symmetric(horizontal: 0.5),
decoration: BoxDecoration(
color: theme.colorScheme.primary.withValues(alpha: intensity * 0.6),
borderRadius: BorderRadius.circular(1),
),
),
);
}).toList(),
);
}
String _formatTime(DateTime time) {
return '${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}:${time.second.toString().padLeft(2, '0')}';
}
}

View File

@@ -0,0 +1,482 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
async:
dependency: transitive
description:
name: async
sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb"
url: "https://pub.dev"
source: hosted
version: "2.13.0"
boolean_selector:
dependency: transitive
description:
name: boolean_selector
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
characters:
dependency: transitive
description:
name: characters
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
url: "https://pub.dev"
source: hosted
version: "1.4.0"
clock:
dependency: transitive
description:
name: clock
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
url: "https://pub.dev"
source: hosted
version: "1.1.2"
collection:
dependency: "direct main"
description:
name: collection
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
url: "https://pub.dev"
source: hosted
version: "1.19.1"
cross_file:
dependency: transitive
description:
name: cross_file
sha256: "7caf6a750a0c04effbb52a676dce9a4a592e10ad35c34d6d2d0e4811160d5670"
url: "https://pub.dev"
source: hosted
version: "0.3.4+2"
crypto:
dependency: transitive
description:
name: crypto
sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855"
url: "https://pub.dev"
source: hosted
version: "3.0.6"
fake_async:
dependency: transitive
description:
name: fake_async
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
url: "https://pub.dev"
source: hosted
version: "1.3.3"
ffi:
dependency: transitive
description:
name: ffi
sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418"
url: "https://pub.dev"
source: hosted
version: "2.1.4"
file:
dependency: transitive
description:
name: file
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
url: "https://pub.dev"
source: hosted
version: "7.0.1"
fixnum:
dependency: transitive
description:
name: fixnum
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
url: "https://pub.dev"
source: hosted
version: "1.1.1"
flutter:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_lints:
dependency: "direct dev"
description:
name: flutter_lints
sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1"
url: "https://pub.dev"
source: hosted
version: "5.0.0"
flutter_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
flutter_web_plugins:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
intl:
dependency: "direct main"
description:
name: intl
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
url: "https://pub.dev"
source: hosted
version: "0.20.2"
leak_tracker:
dependency: transitive
description:
name: leak_tracker
sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0"
url: "https://pub.dev"
source: hosted
version: "10.0.9"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573
url: "https://pub.dev"
source: hosted
version: "3.0.9"
leak_tracker_testing:
dependency: transitive
description:
name: leak_tracker_testing
sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3"
url: "https://pub.dev"
source: hosted
version: "3.0.1"
lints:
dependency: transitive
description:
name: lints
sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7
url: "https://pub.dev"
source: hosted
version: "5.1.1"
logging:
dependency: "direct main"
description:
name: logging
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
url: "https://pub.dev"
source: hosted
version: "1.3.0"
matcher:
dependency: transitive
description:
name: matcher
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
url: "https://pub.dev"
source: hosted
version: "0.12.17"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
url: "https://pub.dev"
source: hosted
version: "0.11.1"
meta:
dependency: transitive
description:
name: meta
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
url: "https://pub.dev"
source: hosted
version: "1.16.0"
mime:
dependency: transitive
description:
name: mime
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
url: "https://pub.dev"
source: hosted
version: "2.0.0"
path:
dependency: "direct main"
description:
name: path
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
url: "https://pub.dev"
source: hosted
version: "1.9.1"
path_provider:
dependency: "direct main"
description:
name: path_provider
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
url: "https://pub.dev"
source: hosted
version: "2.1.5"
path_provider_android:
dependency: transitive
description:
name: path_provider_android
sha256: "993381400e94d18469750e5b9dcb8206f15bc09f9da86b9e44a9b0092a0066db"
url: "https://pub.dev"
source: hosted
version: "2.2.18"
path_provider_foundation:
dependency: transitive
description:
name: path_provider_foundation
sha256: "16eef174aacb07e09c351502740fa6254c165757638eba1e9116b0a781201bbd"
url: "https://pub.dev"
source: hosted
version: "2.4.2"
path_provider_linux:
dependency: transitive
description:
name: path_provider_linux
sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279
url: "https://pub.dev"
source: hosted
version: "2.2.1"
path_provider_platform_interface:
dependency: transitive
description:
name: path_provider_platform_interface
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
path_provider_windows:
dependency: transitive
description:
name: path_provider_windows
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
url: "https://pub.dev"
source: hosted
version: "2.3.0"
platform:
dependency: transitive
description:
name: platform
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
url: "https://pub.dev"
source: hosted
version: "3.1.6"
plugin_platform_interface:
dependency: transitive
description:
name: plugin_platform_interface
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
url: "https://pub.dev"
source: hosted
version: "2.1.8"
share_plus:
dependency: "direct main"
description:
name: share_plus
sha256: d7dc0630a923883c6328ca31b89aa682bacbf2f8304162d29f7c6aaff03a27a1
url: "https://pub.dev"
source: hosted
version: "11.1.0"
share_plus_platform_interface:
dependency: transitive
description:
name: share_plus_platform_interface
sha256: "88023e53a13429bd65d8e85e11a9b484f49d4c190abbd96c7932b74d6927cc9a"
url: "https://pub.dev"
source: hosted
version: "6.1.0"
sky_engine:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
source_span:
dependency: transitive
description:
name: source_span
sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c"
url: "https://pub.dev"
source: hosted
version: "1.10.1"
sprintf:
dependency: transitive
description:
name: sprintf
sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23"
url: "https://pub.dev"
source: hosted
version: "7.0.0"
sqflite:
dependency: "direct main"
description:
name: sqflite
sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03
url: "https://pub.dev"
source: hosted
version: "2.4.2"
sqflite_android:
dependency: transitive
description:
name: sqflite_android
sha256: "2b3070c5fa881839f8b402ee4a39c1b4d561704d4ebbbcfb808a119bc2a1701b"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
sqflite_common:
dependency: transitive
description:
name: sqflite_common
sha256: "6ef422a4525ecc601db6c0a2233ff448c731307906e92cabc9ba292afaae16a6"
url: "https://pub.dev"
source: hosted
version: "2.5.6"
sqflite_darwin:
dependency: transitive
description:
name: sqflite_darwin
sha256: "279832e5cde3fe99e8571879498c9211f3ca6391b0d818df4e17d9fff5c6ccb3"
url: "https://pub.dev"
source: hosted
version: "2.4.2"
sqflite_platform_interface:
dependency: transitive
description:
name: sqflite_platform_interface
sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920"
url: "https://pub.dev"
source: hosted
version: "2.4.0"
stack_trace:
dependency: transitive
description:
name: stack_trace
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
url: "https://pub.dev"
source: hosted
version: "1.12.1"
stream_channel:
dependency: transitive
description:
name: stream_channel
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
url: "https://pub.dev"
source: hosted
version: "2.1.4"
string_scanner:
dependency: transitive
description:
name: string_scanner
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
url: "https://pub.dev"
source: hosted
version: "1.4.1"
synchronized:
dependency: transitive
description:
name: synchronized
sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0
url: "https://pub.dev"
source: hosted
version: "3.4.0"
term_glyph:
dependency: transitive
description:
name: term_glyph
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
url: "https://pub.dev"
source: hosted
version: "1.2.2"
test_api:
dependency: transitive
description:
name: test_api
sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd
url: "https://pub.dev"
source: hosted
version: "0.7.4"
typed_data:
dependency: transitive
description:
name: typed_data
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
url: "https://pub.dev"
source: hosted
version: "1.4.0"
url_launcher_linux:
dependency: transitive
description:
name: url_launcher_linux
sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935"
url: "https://pub.dev"
source: hosted
version: "3.2.1"
url_launcher_platform_interface:
dependency: transitive
description:
name: url_launcher_platform_interface
sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
url: "https://pub.dev"
source: hosted
version: "2.3.2"
url_launcher_web:
dependency: transitive
description:
name: url_launcher_web
sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
url_launcher_windows:
dependency: transitive
description:
name: url_launcher_windows
sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77"
url: "https://pub.dev"
source: hosted
version: "3.1.4"
uuid:
dependency: transitive
description:
name: uuid
sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff
url: "https://pub.dev"
source: hosted
version: "4.5.1"
vector_math:
dependency: transitive
description:
name: vector_math
sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803"
url: "https://pub.dev"
source: hosted
version: "2.1.4"
vm_service:
dependency: transitive
description:
name: vm_service
sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02
url: "https://pub.dev"
source: hosted
version: "15.0.0"
web:
dependency: transitive
description:
name: web
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
url: "https://pub.dev"
source: hosted
version: "1.1.1"
win32:
dependency: transitive
description:
name: win32
sha256: "66814138c3562338d05613a6e368ed8cfb237ad6d64a9e9334be3f309acfca03"
url: "https://pub.dev"
source: hosted
version: "5.14.0"
xdg_directories:
dependency: transitive
description:
name: xdg_directories
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
sdks:
dart: ">=3.8.0 <4.0.0"
flutter: ">=3.29.0"

View File

@@ -0,0 +1,25 @@
name: log_viewer
description: In-app log viewer with filtering capabilities for Ente apps
version: 1.0.0
environment:
sdk: ">=3.0.0 <4.0.0"
flutter: ">=3.0.0"
dependencies:
collection: ^1.18.0
flutter:
sdk: flutter
intl: ^0.20.2
logging: ^1.3.0 # For LogRecord type compatibility
path: ^1.9.0
path_provider: ^2.1.5
share_plus: ^11.0.0 # For log export functionality
sqflite: ^2.4.2
dev_dependencies:
flutter_lints: ^5.0.0
flutter_test:
sdk: flutter
flutter:

View File

@@ -858,7 +858,12 @@ func runServer(environment string, server *gin.Engine) {
log.Fatal(server.RunTLS(":443", certPath, keyPath))
} else {
server.Run(":8080")
port := 8080
if viper.IsSet("http.port") {
port = viper.GetInt("http.port")
}
log.Infof("starting server on port %d", port)
server.Run(fmt.Sprintf(":%d", port))
}
}

View File

@@ -71,8 +71,13 @@ log-file: ""
# HTTP connection parameters
http:
# If true, bind to 443 and use TLS.
# By default, this is false, and museum will bind to 8080 without TLS.
# The port to bind to.
# If not specified, defaults to 8080 (HTTP) or 443 (HTTPS with use-tls: true)
# port: 8080
# If true, use TLS for HTTPS connections.
# When true and port is not specified, defaults to port 443.
# When false and port is not specified, defaults to port 8080.
# use-tls: true
# Specify the base endpoints for various apps

View File

@@ -3,6 +3,7 @@ package ente
import (
"fmt"
"github.com/ente-io/stacktrace"
"golang.org/x/net/idna"
"regexp"
"strings"
)
@@ -139,8 +140,17 @@ func isValidDomainWithoutScheme(input string) error {
if strings.Contains(trimmed, "://") {
return NewBadRequestWithMessage("domain should not contain scheme (e.g., http:// or https://)")
}
if !domainRegex.MatchString(trimmed) {
// Convert IDN to ASCII (Punycode) for validation
asciiDomain, err := idna.ToASCII(trimmed)
if err != nil {
return NewBadRequestWithMessage(fmt.Sprintf("invalid idn domain format: %s", trimmed))
}
// Validate the ASCII version
if !domainRegex.MatchString(asciiDomain) {
return NewBadRequestWithMessage(fmt.Sprintf("invalid domain format: %s", trimmed))
}
return nil
}

View File

@@ -11,7 +11,9 @@ func TestIsValidDomainWithoutScheme(t *testing.T) {
// ✅ Valid cases
{"simple domain", "google.com", false},
{"multi-level domain", "sub.example.co.in", false},
{"multi-level domain", "photos.ä.com", false},
{"numeric in label", "a1b2c3.com", false},
{"idn", "テスト.jp", false},
{"long but valid label", "my-very-long-subdomain-name.example.com", false},
// ❌ Leading/trailing spaces

View File

@@ -5,6 +5,7 @@ import (
"context"
"crypto/sha256"
"fmt"
"golang.org/x/net/idna"
"net/http"
"net/url"
"strings"
@@ -31,7 +32,7 @@ import (
)
var passwordWhiteListedURLs = []string{"/public-collection/info", "/public-collection/report-abuse", "/public-collection/verify-password"}
var whitelistedCollectionShareIDs = []int64{111}
var whitelistedCollectionShareIDs = []int64{111, 12172}
// CollectionLinkMiddleware intercepts and authenticates incoming requests
type CollectionLinkMiddleware struct {
@@ -191,7 +192,9 @@ func (m *CollectionLinkMiddleware) validatePassword(c *gin.Context, reqPath stri
func (m *CollectionLinkMiddleware) validateOrigin(c *gin.Context, ownerID int64) error {
origin := c.Request.Header.Get("Origin")
if origin == "" || origin == viper.GetString("apps.public-albums") {
if origin == "" ||
origin == viper.GetString("apps.public-albums") ||
strings.HasSuffix(strings.ToLower(origin), "http://localhost:") {
return nil
}
reqId := requestid.Get(c)
@@ -218,11 +221,26 @@ func (m *CollectionLinkMiddleware) validateOrigin(c *gin.Context, ownerID int64)
m.DiscordController.NotifyPotentialAbuse(alertMessage + " - originParseFailed")
return nil
}
if !strings.Contains(strings.ToLower(parse.Host), strings.ToLower(*domain)) {
logger.Warnf("domainMismatch for owner %d, origin %s, domain %s host %s", ownerID, origin, *domain, parse.Host)
unicodeDomain, err := idna.ToUnicode(*domain)
if err != nil {
logger.WithError(err).Error("domainToUnicodeFailed")
m.DiscordController.NotifyPotentialAbuse(alertMessage + " - domainToUnicodeFailed")
return nil
}
if !strings.Contains(strings.ToLower(parse.Host), strings.ToLower(*domain)) && !strings.Contains(strings.ToLower(parse.Host), strings.ToLower(unicodeDomain)) {
logger.Warnf("domainMismatch: domain %s (unicode %s) vs originHost %s", *domain, unicodeDomain, parse.Host)
m.DiscordController.NotifyPotentialAbuse(alertMessage + " - domainMismatch")
return ente.NewPermissionDeniedError("unknown custom domain")
}
// Additional exact match check. In the future, remove the contains check above and only keep this exact match check.
if !strings.EqualFold(parse.Host, *domain) && !strings.EqualFold(parse.Host, unicodeDomain) {
logger.Warnf("exactDomainMismatch: domain %s (unicode %s) vs originHost %s", *domain, unicodeDomain, parse.Host)
m.DiscordController.NotifyPotentialAbuse(alertMessage + " - exactDomainMismatch")
// Do not return error here till we are fully sure that this won't cause any issues for existing
// custom domains.
// return ente.NewPermissionDeniedError("unknown custom domain")
}
return nil
}

View File

@@ -486,7 +486,7 @@
"watch_folders": "Watch folders",
"watched_folders": "Watched folders",
"no_folders_added": "No folders added yet",
"watch_folders_hint_1": "The folders you add here will monitored to automatically",
"watch_folders_hint_1": "The folders you add here are monitored to automatically",
"watch_folders_hint_2": "Upload new files to Ente",
"watch_folders_hint_3": "Remove deleted files from Ente",
"add_folder": "Add folder",