Compare commits
63 Commits
meta_files
...
swipe_imag
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74994a3b63 | ||
|
|
7d4897b08f | ||
|
|
c82b829fe3 | ||
|
|
1dbdb270b4 | ||
|
|
1d1efc286f | ||
|
|
dc500795a1 | ||
|
|
11afcd92af | ||
|
|
f20c8caff0 | ||
|
|
074f68146f | ||
|
|
68caa3f7c6 | ||
|
|
5e5d5f4aad | ||
|
|
8713dd0707 | ||
|
|
102313f686 | ||
|
|
7ef9fdcaaa | ||
|
|
d902733809 | ||
|
|
0ef990de5a | ||
|
|
7722c4e16b | ||
|
|
6f5fdfb7b7 | ||
|
|
135124a487 | ||
|
|
d3c53794cf | ||
|
|
270cee8b09 | ||
|
|
9b05cc8c23 | ||
|
|
5b6c3e1b6e | ||
|
|
636793d5b1 | ||
|
|
700e52d11a | ||
|
|
82c7d1865c | ||
|
|
f08ee15cea | ||
|
|
575314c8a1 | ||
|
|
2684f9ce11 | ||
|
|
20afda080d | ||
|
|
b95640f0f7 | ||
|
|
92974a5d6e | ||
|
|
a26643d932 | ||
|
|
757cb486f8 | ||
|
|
512cbb29b3 | ||
|
|
0bc8029541 | ||
|
|
b5febe9ba4 | ||
|
|
fb0179b081 | ||
|
|
289f969d08 | ||
|
|
1b60a4fb38 | ||
|
|
9000529509 | ||
|
|
18872a329a | ||
|
|
dc843eb618 | ||
|
|
009dec1e08 | ||
|
|
82032a182b | ||
|
|
4bd21e2a7a | ||
|
|
278dd83a3d | ||
|
|
0a04c3b4aa | ||
|
|
6b760fd611 | ||
|
|
2518bf5b2d | ||
|
|
a0eb38c584 | ||
|
|
e32ac8d3b5 | ||
|
|
1d02d18937 | ||
|
|
c34cfdcb54 | ||
|
|
ef66d422b7 | ||
|
|
dc5c3d8af6 | ||
|
|
bb5c0db8d3 | ||
|
|
76c5f5cbb6 | ||
|
|
5446f8dd68 | ||
|
|
52e3f22abf | ||
|
|
0caaf8b966 | ||
|
|
06a05659a6 | ||
|
|
f014a36eba |
4
.github/workflows/web-deploy-one.yml
vendored
4
.github/workflows/web-deploy-one.yml
vendored
@@ -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 }}
|
||||
|
||||
4
.github/workflows/web-deploy-preview.yml
vendored
4
.github/workflows/web-deploy-preview.yml
vendored
@@ -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 }}
|
||||
|
||||
3
.github/workflows/web-deploy-staging.yml
vendored
3
.github/workflows/web-deploy-staging.yml
vendored
@@ -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
|
||||
|
||||
12
.github/workflows/web-deploy.yml
vendored
12
.github/workflows/web-deploy.yml
vendored
@@ -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
|
||||
|
||||
12
.github/workflows/web-lint.yml
vendored
12
.github/workflows/web-lint.yml
vendored
@@ -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
113
CLAUDE.md
Normal 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
|
||||
@@ -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
|
||||
|
||||
795
mobile/apps/photos/.claude/photo_swipe_culling/feature_plan.md
Normal file
795
mobile/apps/photos/.claude/photo_swipe_culling/feature_plan.md
Normal 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
|
||||
@@ -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 |
134
mobile/apps/photos/.claude/photo_swipe_culling/progress.md
Normal file
134
mobile/apps/photos/.claude/photo_swipe_culling/progress.md
Normal 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 |
1
mobile/apps/photos/AGENTS.md
Symbolic link
1
mobile/apps/photos/AGENTS.md
Symbolic link
@@ -0,0 +1 @@
|
||||
CLAUDE.md
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import "package:adaptive_theme/adaptive_theme.dart";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
211
mobile/apps/photos/lib/ui/sharing/qr_code_dialog_widget.dart
Normal file
211
mobile/apps/photos/lib/ui/sharing/qr_code_dialog_widget.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
56
mobile/packages/log_viewer/README.md
Normal file
56
mobile/packages/log_viewer/README.md
Normal 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
|
||||
1
mobile/packages/log_viewer/analysis_options.yaml
Normal file
1
mobile/packages/log_viewer/analysis_options.yaml
Normal file
@@ -0,0 +1 @@
|
||||
include: ../../analysis_options.yaml
|
||||
327
mobile/packages/log_viewer/example_integration.md
Normal file
327
mobile/packages/log_viewer/example_integration.md
Normal 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
|
||||
97
mobile/packages/log_viewer/lib/log_viewer.dart
Normal file
97
mobile/packages/log_viewer/lib/log_viewer.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
411
mobile/packages/log_viewer/lib/src/core/log_database.dart
Normal file
411
mobile/packages/log_viewer/lib/src/core/log_database.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
255
mobile/packages/log_viewer/lib/src/core/log_models.dart
Normal file
255
mobile/packages/log_viewer/lib/src/core/log_models.dart
Normal 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,
|
||||
});
|
||||
}
|
||||
225
mobile/packages/log_viewer/lib/src/core/log_store.dart
Normal file
225
mobile/packages/log_viewer/lib/src/core/log_store.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
197
mobile/packages/log_viewer/lib/src/ui/log_detail_page.dart
Normal file
197
mobile/packages/log_viewer/lib/src/ui/log_detail_page.dart
Normal 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),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
294
mobile/packages/log_viewer/lib/src/ui/log_filter_dialog.dart
Normal file
294
mobile/packages/log_viewer/lib/src/ui/log_filter_dialog.dart
Normal 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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
95
mobile/packages/log_viewer/lib/src/ui/log_list_tile.dart
Normal file
95
mobile/packages/log_viewer/lib/src/ui/log_list_tile.dart
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
639
mobile/packages/log_viewer/lib/src/ui/log_viewer_page.dart
Normal file
639
mobile/packages/log_viewer/lib/src/ui/log_viewer_page.dart
Normal 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),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
272
mobile/packages/log_viewer/lib/src/ui/timeline_widget.dart
Normal file
272
mobile/packages/log_viewer/lib/src/ui/timeline_widget.dart
Normal 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')}';
|
||||
}
|
||||
}
|
||||
482
mobile/packages/log_viewer/pubspec.lock
Normal file
482
mobile/packages/log_viewer/pubspec.lock
Normal 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"
|
||||
25
mobile/packages/log_viewer/pubspec.yaml
Normal file
25
mobile/packages/log_viewer/pubspec.yaml
Normal 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:
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user