From 2903a79654d7a9473464c4562ea3a6ea04b4d8d3 Mon Sep 17 00:00:00 2001 From: ultraelectronica Date: Tue, 30 Jun 2026 20:58:37 +0800 Subject: [PATCH 01/34] Add animated logo entrance to auth method selection screen --- lib/screens/auth_method_selection_screen.dart | 307 ++++++++++++++---- 1 file changed, 236 insertions(+), 71 deletions(-) diff --git a/lib/screens/auth_method_selection_screen.dart b/lib/screens/auth_method_selection_screen.dart index 5dcb1c1..36e8bb6 100644 --- a/lib/screens/auth_method_selection_screen.dart +++ b/lib/screens/auth_method_selection_screen.dart @@ -1,12 +1,13 @@ import 'package:flutter/material.dart'; import '../themes/app_colors.dart'; +import '../widgets/animated_latch_logo.dart'; import '../widgets/auth_method_card.dart'; import 'pin_setup_screen.dart'; import 'password_setup_screen.dart'; import 'biometric_setup_screen.dart'; import '../services/auth_service.dart'; -/// First-time authentication method selection screen +/// First-time authentication method selection screen. class AuthMethodSelectionScreen extends StatefulWidget { const AuthMethodSelectionScreen({super.key}); @@ -15,9 +16,31 @@ class AuthMethodSelectionScreen extends StatefulWidget { _AuthMethodSelectionScreenState(); } -class _AuthMethodSelectionScreenState extends State { +class _AuthMethodSelectionScreenState extends State + with SingleTickerProviderStateMixin { + static const double _logoSize = 80; + final AuthService _authService = AuthService(); bool _isBiometricAvailable = false; + bool _launched = false; + bool _liftReady = false; + + late final AnimationController _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1300), + ); + + // Assemble + glow: no layout dependency, so build the curves once. + late final Animation _assemble = CurvedAnimation( + parent: _controller, curve: const Interval(0.0, 0.40, curve: Curves.easeOut)); + late final Animation _glowFade = Tween(begin: 0.0, end: 0.6).animate( + CurvedAnimation(parent: _controller, curve: const Interval(0.0, 0.30, curve: Curves.easeOut))); + late final Animation _glowScale = Tween(begin: 0.8, end: 1.0).animate( + CurvedAnimation(parent: _controller, curve: const Interval(0.0, 0.30, curve: Curves.easeOut))); + + // Lift: needs screen height, so it's built in didChangeDependencies. + late Animation _liftTranslate; + late Animation _liftScale; @override void initState() { @@ -25,11 +48,66 @@ class _AuthMethodSelectionScreenState extends State { _checkBiometricAvailability(); } + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final mq = MediaQuery.of(context); + if (!_liftReady) { + _liftReady = true; + // Logo's natural center Y from screen top: safe area + 24 padding + 32 spacer + half logo. + final logoCenterY = mq.padding.top + 24.0 + 32.0 + _logoSize / 2; + final delta = mq.size.height / 2 - logoCenterY; + _liftTranslate = Tween(begin: delta, end: 0.0).animate(CurvedAnimation( + parent: _controller, curve: const Interval(0.32, 0.58, curve: Curves.easeInOutCubic))); + _liftScale = Tween(begin: 1.3, end: 1.0).animate(CurvedAnimation( + parent: _controller, curve: const Interval(0.32, 0.58, curve: Curves.easeInOutCubic))); + } + if (!_launched) { + _launched = true; + if (mq.disableAnimations) { + _controller.value = 1.0; + } else { + _controller.forward(); + } + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + Future _checkBiometricAvailability() async { final isAvailable = await _authService.isBiometricAvailable(); - setState(() { - _isBiometricAvailable = isAvailable; - }); + if (!mounted) return; + setState(() => _isBiometricAvailable = isAvailable); + } + + Animation _fadeIn(double a, double b, [Curve c = Curves.easeOut]) => + Tween(begin: 0.0, end: 1.0).animate( + CurvedAnimation(parent: _controller, curve: Interval(a, b, curve: c))); + + Widget _entrance({ + required double a, + required double b, + required double dy, + required Widget child, + Curve curve = Curves.easeOut, + }) { + final fade = _fadeIn(a, b, curve); + final slide = _fadeIn(a, b, curve); + return AnimatedBuilder( + animation: _controller, + builder: (_, c) => Opacity( + opacity: fade.value, + child: Transform.translate( + offset: Offset(0, (1 - slide.value) * dy), + child: c, + ), + ), + child: child, + ); } @override @@ -40,101 +118,130 @@ class _AuthMethodSelectionScreenState extends State { child: Padding( padding: const EdgeInsets.all(24.0), child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, children: [ const SizedBox(height: 32), - // Welcome Title - Text( - 'Welcome to Latch', - style: TextStyle( - fontSize: 32, - fontWeight: FontWeight.w700, - color: AppColors.lightTextPrimary, - fontFamily: 'ProductSans', + // Assembles at screen center, then lifts to this top slot. + AnimatedBuilder( + animation: _controller, + builder: (_, child) => Transform.translate( + offset: Offset(0, _liftTranslate.value), + child: Transform.scale(scale: _liftScale.value, child: child), + ), + child: _LogoEntrance( + assemble: _assemble, + glowFade: _glowFade, + glowScale: _glowScale, + glowColor: context.accentColor, + logoColor: context.textPrimary, + size: _logoSize, + ), + ), + + const SizedBox(height: 24), + + _entrance( + a: 0.52, + b: 0.70, + dy: 24, + child: Text( + 'Welcome to Latch', + style: TextStyle( + fontSize: 32, + fontWeight: FontWeight.w700, + color: context.textPrimary, + fontFamily: 'ProductSans', + ), ), ), const SizedBox(height: 12), - // Subtitle - Text( - 'Choose how you want to secure your media vault', - style: TextStyle( - fontSize: 16, - color: AppColors.lightTextSecondary, - fontFamily: 'ProductSans', + _entrance( + a: 0.60, + b: 0.76, + dy: 20, + child: Text( + 'Choose how you want to secure your media vault', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 16, + color: context.textSecondary, + fontFamily: 'ProductSans', + ), ), ), const SizedBox(height: 48), - // Authentication Method Options Expanded( child: ListView( children: [ - // PIN Option - AuthMethodCard( - icon: Icons.pin_outlined, - title: 'PIN', - description: '6-digit numeric code', - onTap: () { - Navigator.push( + _entrance( + a: 0.64, + b: 0.80, + dy: 28, + child: AuthMethodCard( + icon: Icons.pin_outlined, + title: 'PIN', + description: '6-digit numeric code', + onTap: () => Navigator.push( context, MaterialPageRoute( builder: (context) => const PinSetupScreen(), ), - ); - }, + ), + ), ), - const SizedBox(height: 16), - - // Password Option - AuthMethodCard( - icon: Icons.lock_outlined, - title: 'Password', - description: 'Alphanumeric password', - onTap: () { - Navigator.push( + _entrance( + a: 0.70, + b: 0.84, + dy: 28, + child: AuthMethodCard( + icon: Icons.lock_outlined, + title: 'Password', + description: 'Alphanumeric password', + onTap: () => Navigator.push( context, MaterialPageRoute( builder: (context) => const PasswordSetupScreen(), ), - ); - }, + ), + ), ), - const SizedBox(height: 16), - - // Biometric Option - AuthMethodCard( - icon: Icons.fingerprint, - title: 'Biometrics', - description: _isBiometricAvailable - ? 'Use your fingerprint' - : 'Not available on this device', - onTap: _isBiometricAvailable - ? () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => - const BiometricSetupScreen(), - ), - ); - } - : () { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - 'Biometric authentication is not available on this device', - style: TextStyle(fontFamily: 'ProductSans'), + _entrance( + a: 0.76, + b: 0.90, + dy: 28, + child: AuthMethodCard( + icon: Icons.fingerprint, + title: 'Biometrics', + description: _isBiometricAvailable + ? 'Use your fingerprint' + : 'Not available on this device', + onTap: _isBiometricAvailable + ? () => Navigator.push( + context, + MaterialPageRoute( + builder: (context) => + const BiometricSetupScreen(), ), - backgroundColor: AppColors.error, - ), - ); - }, + ) + : () { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Biometric authentication is not available on this device', + style: TextStyle(fontFamily: 'ProductSans'), + ), + backgroundColor: AppColors.error, + ), + ); + }, + ), ), ], ), @@ -146,3 +253,61 @@ class _AuthMethodSelectionScreenState extends State { ); } } + +class _LogoEntrance extends StatelessWidget { + final Animation assemble; + final Animation glowFade; + final Animation glowScale; + final Color glowColor; + final Color logoColor; + final double size; + + const _LogoEntrance({ + required this.assemble, + required this.glowFade, + required this.glowScale, + required this.glowColor, + required this.logoColor, + required this.size, + }); + + @override + Widget build(BuildContext context) { + return SizedBox( + width: size, + height: size, + child: Stack( + alignment: Alignment.center, + children: [ + AnimatedBuilder( + animation: glowFade, + builder: (_, __) => Opacity( + opacity: glowFade.value, + child: Transform.scale( + scale: glowScale.value, + child: Container( + width: 176, + height: 176, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: RadialGradient( + colors: [ + glowColor.withValues(alpha: 0.5), + glowColor.withValues(alpha: 0.0), + ], + ), + ), + ), + ), + ), + ), + AnimatedLatchLogo( + progress: assemble, + size: size, + color: logoColor, + ), + ], + ), + ); + } +} From 74f3f2bbd11ef788499e8132cbf8181a254ce2e5 Mon Sep 17 00:00:00 2001 From: ultraelectronica Date: Tue, 30 Jun 2026 20:58:42 +0800 Subject: [PATCH 02/34] Make setup buttons span full width --- lib/screens/biometric_setup_screen.dart | 1 + lib/screens/password_setup_screen.dart | 1 + 2 files changed, 2 insertions(+) diff --git a/lib/screens/biometric_setup_screen.dart b/lib/screens/biometric_setup_screen.dart index 000b5ac..ced0d9c 100644 --- a/lib/screens/biometric_setup_screen.dart +++ b/lib/screens/biometric_setup_screen.dart @@ -216,6 +216,7 @@ class _BiometricSetupScreenState extends State { Widget _buildSetupButton() { return Container( + width: double.infinity, decoration: BoxDecoration( borderRadius: BorderRadius.circular(16), gradient: LinearGradient( diff --git a/lib/screens/password_setup_screen.dart b/lib/screens/password_setup_screen.dart index ba602ec..424871b 100644 --- a/lib/screens/password_setup_screen.dart +++ b/lib/screens/password_setup_screen.dart @@ -314,6 +314,7 @@ class _PasswordSetupScreenState extends State { Widget _buildContinueButton() { return Container( + width: double.infinity, decoration: BoxDecoration( borderRadius: BorderRadius.circular(16), gradient: LinearGradient( From 633ea45433e79fbe5dd88b6523e250ae22c294b4 Mon Sep 17 00:00:00 2001 From: ultraelectronica Date: Tue, 30 Jun 2026 20:58:50 +0800 Subject: [PATCH 03/34] Add cooperative cancellation token for decrypt pipeline --- lib/services/crypto_isolate_pool.dart | 29 ++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/lib/services/crypto_isolate_pool.dart b/lib/services/crypto_isolate_pool.dart index 0364029..a12255a 100644 --- a/lib/services/crypto_isolate_pool.dart +++ b/lib/services/crypto_isolate_pool.dart @@ -3,7 +3,6 @@ import 'dart:convert'; import 'dart:io'; import 'dart:isolate'; import 'dart:math'; -import 'dart:typed_data'; import 'package:flutter/foundation.dart'; import 'package:pointycastle/export.dart'; @@ -303,6 +302,34 @@ class CryptoJob { }); } +/// Cooperative cancellation handle threaded through the decrypt pipeline. +/// +/// Bundles two things the open flow needs to cancel cleanly: +/// - a flag the orchestrator checks at stage boundaries (e.g. after the +/// un-killable PBKDF2 key derivation, before dispatching decrypt), and +/// - an isolate-kill hook [bind]ed by the crypto layer when a killable job +/// actually starts. +/// +/// [cancel] is idempotent. Derivation runs on `compute` and cannot be killed +/// mid-call; cancelling during derive flips the flag so the next boundary +/// aborts, and the orphaned derive result is discarded (bounded cost). +class CancelToken { + bool _cancelled = false; + VoidCallback? _kill; + + bool get isCancelled => _cancelled; + + void cancel() { + if (_cancelled) return; + _cancelled = true; + _kill?.call(); + } + + /// Attach the isolate job's kill handle. Called by the crypto layer when a + /// killable job starts. Single-use per token. + void bind(VoidCallback kill) => _kill = kill; +} + class PoolEncryptResult { final bool success; final String? ivBase64; From 0b9ec3118a20b25802e33b48b9f26e0528df9fdd Mon Sep 17 00:00:00 2001 From: ultraelectronica Date: Tue, 30 Jun 2026 20:59:00 +0800 Subject: [PATCH 04/34] Add cancel token support to isolate file decryption --- lib/services/encryption_service.dart | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/lib/services/encryption_service.dart b/lib/services/encryption_service.dart index ea69caa..157618f 100644 --- a/lib/services/encryption_service.dart +++ b/lib/services/encryption_service.dart @@ -1337,7 +1337,14 @@ class EncryptionService { } } - /// Decrypt file in isolate pool (for large files, with real progress) + /// Decrypt file in isolate pool (for large files, with real progress). + /// + /// [cancelToken], if provided, is bound to the isolate job's kill handle as + /// soon as the job dispatches, so the caller can cancel it. Cancellation is + /// safe: the worker writes to `destinationPath.tmp` and only renames to + /// `destinationPath` on success, so killing it leaves no partial final file — + /// only an orphaned `.tmp` that VaultService.cleanupTemp reaps. The source + /// encrypted file is read-only. Future decryptFileInIsolate( String encryptedPath, String destinationPath, @@ -1345,6 +1352,7 @@ class EncryptionService { bool isDecoy = false, Uint8List? derivedKey, Function(int bytesProcessed, int totalBytes)? onProgress, + CancelToken? cancelToken, }) async { try { final encryptedFile = File(encryptedPath); @@ -1355,8 +1363,16 @@ class EncryptionService { ); } + if (cancelToken?.isCancelled == true) { + return FileDecryptionResult(success: false, error: 'Cancelled'); + } + final key = await _resolveKey(isDecoy: isDecoy, derivedKey: derivedKey); + if (cancelToken?.isCancelled == true) { + return FileDecryptionResult(success: false, error: 'Cancelled'); + } + final job = _pool!.decryptFile( encryptedPath: encryptedPath, destinationPath: destinationPath, @@ -1364,6 +1380,7 @@ class EncryptionService { ivBase64: ivBase64, onProgress: onProgress, ); + cancelToken?.bind(job.cancel); final result = await job.future; From 92320028dfcf56aec90c544d9f99036dc0468969 Mon Sep 17 00:00:00 2001 From: ultraelectronica Date: Tue, 30 Jun 2026 20:59:04 +0800 Subject: [PATCH 05/34] Refactor file open service with phased progress and cancel support --- lib/services/file_open_service.dart | 272 ++++++++++++++++++++++------ 1 file changed, 218 insertions(+), 54 deletions(-) diff --git a/lib/services/file_open_service.dart b/lib/services/file_open_service.dart index e529072..9b3c383 100644 --- a/lib/services/file_open_service.dart +++ b/lib/services/file_open_service.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:io'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -11,12 +12,25 @@ import '../screens/media_viewer_screen.dart'; import '../screens/song_player_screen.dart'; import '../themes/app_colors.dart'; import '../utils/toast_utils.dart'; +import 'crypto_isolate_pool.dart'; /// Centralized, atomic file opener. /// -/// Shows a single loading indicator while the file is being prepared (e.g. -/// decrypted) and then pushes the correct viewer screen. Only one file can be -/// opened at a time so repeated taps don't stack routes. +/// Opening an encrypted file runs in distinct, user-visible phases driven by +/// real signals, not a fake 0–100% bar: +/// - **Preparing key…** — indeterminate; covers the un-killable PBKDF2 key +/// derivation (the historically slow step). Explains why nothing moves yet. +/// - **Decrypting…** — stays indeterminate until the isolate emits byte +/// progress, then flips to a determinate bar. Small files never show a +/// lying 0%; they resolve during indeterminate. +/// - **Opening…** — indeterminate, while the viewer route is pushed. +/// +/// Cancel works in every phase via [CancelToken]: it kills the decrypt isolate +/// if running (safe — the worker writes to `.tmp` and only renames on success; +/// orphaned temps are reaped by VaultService.cleanupTemp), and flips a flag +/// checked at stage boundaries so a cancel during key derivation still unblocks +/// the UI instantly. The derivation itself finishes on its background isolate +/// and its result is discarded. class FileOpenService { static bool _isOpening = false; @@ -41,56 +55,52 @@ class FileOpenService { _isOpening = true; - BuildContext? overlayContext; - Timer? overlayTimer; - var overlayShown = false; + final needsDecryption = file.isEncrypted && file.encryptionIv != null; + final token = CancelToken(); + final status = ValueNotifier<_OpenStatus>( + _OpenStatus(needsDecryption ? 'Preparing key…' : 'Opening…'), + ); + BuildContext? sheetContext; + Timer? sheetTimer; + var sheetShown = false; + + void hideSheet() { + sheetTimer?.cancel(); + if (sheetShown && sheetContext?.mounted == true) { + Navigator.pop(sheetContext!); + } + sheetShown = false; + } + + void requestCancel() { + token.cancel(); + hideSheet(); + } - void showOverlay() { - if (overlayShown || !context.mounted) return; - overlayShown = true; - showDialog( + void showSheet() { + if (sheetShown || !context.mounted) return; + sheetShown = true; + showModalBottomSheet( context: context, - barrierDismissible: false, - useRootNavigator: true, + isScrollControlled: true, + isDismissible: false, + enableDrag: false, + showDragHandle: true, + backgroundColor: context.surfaceColor, builder: (ctx) { - overlayContext = ctx; - return PopScope( - canPop: false, - child: AlertDialog( - backgroundColor: Theme.of(ctx).scaffoldBackgroundColor, - content: Row( - children: [ - CircularProgressIndicator( - valueColor: AlwaysStoppedAnimation(ctx.accentColor), - ), - const SizedBox(width: 20), - Expanded( - child: Text( - 'Opening ${file.originalName}...', - style: const TextStyle(fontFamily: 'ProductSans'), - ), - ), - ], - ), - ), + sheetContext = ctx; + return _OpenSheet( + file: file, + status: status, + onCancel: requestCancel, ); }, ); } - void hideOverlay() { - overlayTimer?.cancel(); - if (overlayShown && overlayContext?.mounted == true) { - Navigator.pop(overlayContext!); - overlayShown = false; - overlayContext = null; - } - } - try { final noteId = file.metadata?['noteId'] as String?; if (noteId != null) { - hideOverlay(); if (onOpenNote != null) { onOpenNote(noteId); } else { @@ -101,7 +111,6 @@ class FileOpenService { final passwordId = file.metadata?['passwordId'] as String?; if (passwordId != null) { - hideOverlay(); if (onOpenPassword != null) { onOpenPassword(passwordId); } else { @@ -111,31 +120,51 @@ class FileOpenService { } final vaultService = ref.read(vaultServiceProvider); - final needsDecryption = file.isEncrypted && file.encryptionIv != null; - // Encrypted files are likely to take a noticeable amount of time, so - // show the indicator immediately. Plain files may open almost instantly, - // so defer the overlay slightly to avoid a flash. + // Encrypted files take noticeable time, so show the sheet immediately in + // the prepare phase. Plain files usually open almost instantly, so defer + // slightly to avoid a flash. if (needsDecryption) { - showOverlay(); + showSheet(); } else { - overlayTimer = Timer(const Duration(milliseconds: 150), showOverlay); + sheetTimer = Timer(const Duration(milliseconds: 150), showSheet); } File? decryptedFile; if (needsDecryption) { - decryptedFile = await vaultService.getVaultedFile(file.id); + decryptedFile = await vaultService.getVaultedFile( + file.id, + onProgress: (processed, total) { + // First byte-progress event flips prepare -> decrypt and turns the + // bar determinate. Until then it stays honestly indeterminate. + if (total > 0 && !token.isCancelled) { + status.value = _OpenStatus( + 'Decrypting…', + progress: processed / total, + ); + } + }, + cancelToken: token, + ); + + if (token.isCancelled) return; + if (decryptedFile == null || !await decryptedFile.exists()) { throw Exception('Failed to decrypt ${file.originalName}'); } } if (!context.mounted) { - hideOverlay(); + hideSheet(); return; } - hideOverlay(); + status.value = const _OpenStatus('Opening…'); + + // Plain files defer the sheet 150ms to avoid a flash, but push awaits + // until the route is popped — cancel the pending timer or it surfaces + // the loading sheet on top of the already-opened viewer. + sheetTimer?.cancel(); await _pushViewer( context, @@ -145,12 +174,17 @@ class FileOpenService { onUnsupported: onUnsupported, ); + hideSheet(); + unawaited(vaultService.updateFile(file.markViewed())); } catch (e, st) { debugPrint('Error opening file: $e\n$st'); - hideOverlay(); - ToastUtils.showError('Failed to open ${file.originalName}'); + hideSheet(); + if (!token.isCancelled) { + ToastUtils.showError('Failed to open ${file.originalName}'); + } } finally { + status.dispose(); _isOpening = false; } } @@ -203,3 +237,133 @@ class FileOpenService { } } } + +/// Progress snapshot for the open sheet. [progress] is null while the active +/// phase can't report a fraction (honest indeterminate). +class _OpenStatus { + final String phase; + final double? progress; + + const _OpenStatus(this.phase, {this.progress}); +} + +class _OpenSheet extends StatelessWidget { + final VaultedFile file; + final ValueListenable<_OpenStatus> status; + final VoidCallback onCancel; + + const _OpenSheet({ + required this.file, + required this.status, + required this.onCancel, + }); + + @override + Widget build(BuildContext context) { + final accent = context.accentColor; + final typeColor = FileTypeColors.colorForType(file.type, accent: accent); + + return SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(24, 4, 24, 16), + child: ValueListenableBuilder<_OpenStatus>( + valueListenable: status, + builder: (_, s, __) { + final pct = s.progress == null + ? null + : (s.progress!.clamp(0.0, 1.0) * 100).round(); + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: typeColor.withValues(alpha: 0.15), + shape: BoxShape.circle, + ), + alignment: Alignment.center, + child: Icon( + FileTypeColors.iconForType(file.type), + color: typeColor, + size: 22, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + file.originalName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontFamily: 'ProductSans', + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + Text( + s.phase, + style: TextStyle( + fontFamily: 'ProductSans', + fontSize: 13, + color: context.textSecondary, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 22), + Row( + children: [ + Expanded( + child: LinearProgressIndicator( + value: s.progress, + minHeight: 6, + valueColor: AlwaysStoppedAnimation(accent), + backgroundColor: accent.withValues(alpha: 0.12), + ), + ), + if (pct != null) ...[ + const SizedBox(width: 12), + SizedBox( + width: 44, + child: Text( + '$pct%', + textAlign: TextAlign.right, + style: TextStyle( + fontFamily: 'ProductSans', + fontSize: 13, + fontWeight: FontWeight.w600, + color: context.textSecondary, + ), + ), + ), + ], + ], + ), + const SizedBox(height: 20), + Align( + alignment: Alignment.centerRight, + child: TextButton( + onPressed: onCancel, + child: const Text('Cancel'), + ), + ), + ], + ); + }, + ), + ), + ); + } +} From 8bf1e2ae91090c1a8b609bee56fbe93a1a6a39a4 Mon Sep 17 00:00:00 2001 From: ultraelectronica Date: Tue, 30 Jun 2026 20:59:10 +0800 Subject: [PATCH 06/34] Add version skipping and deduplicate update emission --- lib/services/update_service.dart | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/lib/services/update_service.dart b/lib/services/update_service.dart index 5eefe01..6766fe0 100644 --- a/lib/services/update_service.dart +++ b/lib/services/update_service.dart @@ -7,6 +7,7 @@ import 'package:flutter/services.dart'; import 'package:in_app_update/in_app_update.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:pub_semver/pub_semver.dart'; +import 'package:shared_preferences/shared_preferences.dart'; /// Where this build was installed from. enum InstallSource { playStore, github, unknown } @@ -47,9 +48,11 @@ class UpdateService { static const _releasePageUrl = 'https://github.com/moss-apps/Latch/releases/latest'; static const _fetchTimeout = Duration(seconds: 8); + static const _skippedVersionKey = 'update_skipped_version'; StreamSubscription>? _subscription; bool _isChecking = false; + String? _lastEmittedKey; InstallSource _installSource = InstallSource.unknown; InstallSource get installSource => _installSource; @@ -97,15 +100,35 @@ class UpdateService { } else { _pendingUpdate = await _checkGitHubRelease(); } - _updateController.add(_pendingUpdate); + _emit(_pendingUpdate); } catch (_) { _pendingUpdate = null; - _updateController.add(null); + _emit(null); } finally { _isChecking = false; } } + void _emit(PendingUpdate? update) { + final key = update == null + ? null + : (update.latestVersion ?? update.source.name); + if (key == _lastEmittedKey) return; + _lastEmittedKey = key; + _updateController.add(update); + } + + Future skipVersion(String version) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_skippedVersionKey, version); + } + + Future isVersionSkipped(String? version) async { + if (version == null) return false; + final prefs = await SharedPreferences.getInstance(); + return prefs.getString(_skippedVersionKey) == version; + } + Future _detectInstallSource() async { if (!Platform.isAndroid) return InstallSource.unknown; try { From ce411251023d4180e024198b699de789cd2a4dc9 Mon Sep 17 00:00:00 2001 From: ultraelectronica Date: Tue, 30 Jun 2026 20:59:14 +0800 Subject: [PATCH 07/34] Add cancel token support to getVaultedFile method --- lib/services/vault_service.dart | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/lib/services/vault_service.dart b/lib/services/vault_service.dart index adaf480..0d7c7b5 100644 --- a/lib/services/vault_service.dart +++ b/lib/services/vault_service.dart @@ -10,6 +10,7 @@ import '../models/vault_folder.dart'; import '../models/encryption_algorithm.dart'; import 'encryption_service.dart'; import 'compression_service.dart'; +import 'crypto_isolate_pool.dart'; class FileProgressInfo { final int current; @@ -1136,11 +1137,17 @@ class VaultService { return _encryptionService.deriveFileKeyAsync(masterKey, salt, file.kdfIterations!); } - /// Get the actual file from vault (decrypts if needed) + /// Get the actual file from vault (decrypts if needed). + /// + /// [cancelToken] makes the operation cooperative: it is checked after the + /// (un-killable) key derivation and bound to the decrypt isolate's kill + /// handle, so cancellation during either stage unblocks promptly. A + /// cancelled call returns null and writes no final file. Future getVaultedFile( String fileId, { bool isDecoy = false, Function(int processed, int total)? onProgress, + CancelToken? cancelToken, }) async { final vaultedFile = await getFileById(fileId, isDecoy: isDecoy); if (vaultedFile == null) return null; @@ -1158,6 +1165,10 @@ class VaultService { final isLegacyCbc = (format == 0 || format == 3); final derivedKey = await _deriveKeyForFile(vaultedFile, isDecoy: isDecoy); + // Derive runs on `compute` and can't be killed; abort here if the user + // cancelled during that window so we never dispatch the decrypt job. + if (cancelToken?.isCancelled == true) return null; + FileDecryptionResult result; if (isLegacyCbc) { result = await _encryptionService.decryptFile( @@ -1183,9 +1194,12 @@ class VaultService { isDecoy: isDecoy, derivedKey: derivedKey, onProgress: onProgress, + cancelToken: cancelToken, ); } + if (cancelToken?.isCancelled == true) return null; + if (result.success && result.decryptedPath != null) { return File(result.decryptedPath!); } From a64a1f3e9ea96b42cf07a4713943b325ddadf295 Mon Sep 17 00:00:00 2001 From: ultraelectronica Date: Tue, 30 Jun 2026 20:59:40 +0800 Subject: [PATCH 08/34] Migrate AuthMethodCard to use context-based theming --- lib/widgets/auth_method_card.dart | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/lib/widgets/auth_method_card.dart b/lib/widgets/auth_method_card.dart index b1513b3..aec2fba 100644 --- a/lib/widgets/auth_method_card.dart +++ b/lib/widgets/auth_method_card.dart @@ -19,8 +19,11 @@ class AuthMethodCard extends StatelessWidget { @override Widget build(BuildContext context) { return Card( - elevation: 2, - shadowColor: Colors.black12, + elevation: 0, + shape: RoundedRectangleBorder( + side: BorderSide(color: context.borderColor, width: 1), + borderRadius: BorderRadius.circular(16), + ), child: InkWell( onTap: onTap, borderRadius: BorderRadius.circular(16), @@ -33,13 +36,13 @@ class AuthMethodCard extends StatelessWidget { width: 56, height: 56, decoration: BoxDecoration( - color: AppColors.accentLight.withValues(alpha: 0.1), + color: context.accentColor.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(12), ), child: Icon( icon, size: 32, - color: AppColors.accent, + color: context.accentColor, ), ), @@ -55,7 +58,7 @@ class AuthMethodCard extends StatelessWidget { style: TextStyle( fontSize: 18, fontWeight: FontWeight.w600, - color: AppColors.lightTextPrimary, + color: context.textPrimary, fontFamily: 'ProductSans', ), ), @@ -64,7 +67,7 @@ class AuthMethodCard extends StatelessWidget { description, style: TextStyle( fontSize: 14, - color: AppColors.lightTextSecondary, + color: context.textSecondary, fontFamily: 'ProductSans', ), ), @@ -76,7 +79,7 @@ class AuthMethodCard extends StatelessWidget { Icon( Icons.arrow_forward_ios, size: 20, - color: AppColors.lightTextTertiary, + color: context.textTertiary, ), ], ), From 30aeb6b719dbc1a460b0bf5c261725ba1291f4c2 Mon Sep 17 00:00:00 2001 From: ultraelectronica Date: Tue, 30 Jun 2026 20:59:47 +0800 Subject: [PATCH 09/34] Add transparency section to README --- README.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 3281c23..c369ecc 100644 --- a/README.md +++ b/README.md @@ -136,5 +136,11 @@ Follow the Dart style guide. MIT, see [LICENSE](LICENSE). Free, no ads, no paid features. ## Contributors - + - [@ultraelectronica](https://github.com/ultraelectronica) (creator) + +## Transparency + +I build this with [OpenCode](https://opencode.ai). It writes some of the +comments and docs, and it's bailed me out of more than a few nasty-ass bugs. +The code, the design calls, and the fuck-ups are all mine. From 87fc447ee4c766ca026c5c09035ac115e92a74d2 Mon Sep 17 00:00:00 2001 From: ultraelectronica Date: Tue, 30 Jun 2026 20:59:52 +0800 Subject: [PATCH 10/34] Add skip version feature to update dialog - Allow users to skip a specific update version - Check for skipped versions before showing update dialog --- lib/main.dart | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index 4969a1e..91d79b9 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -83,10 +83,10 @@ class _AppInitializerState extends ConsumerState { _checkAuthStatus(); final updateService = ref.read(updateServiceProvider); - _updateSub = updateService.onUpdateCheck.listen((update) { - if (update != null) { - _showUpdateDialog(update); - } + _updateSub = updateService.onUpdateCheck.listen((update) async { + if (update == null) return; + if (await updateService.isVersionSkipped(update.latestVersion)) return; + _showUpdateDialog(update); }); } @@ -120,6 +120,16 @@ class _AppInitializerState extends ConsumerState { onPressed: () => Navigator.pop(dialogContext), child: const Text('Later'), ), + if (!isPlayStore && update.latestVersion != null) + TextButton( + onPressed: () { + ref + .read(updateServiceProvider) + .skipVersion(update.latestVersion!); + Navigator.pop(dialogContext); + }, + child: const Text("Don't show again"), + ), FilledButton( onPressed: () { Navigator.pop(dialogContext); From 756adb8880426e60f20de25ef5eb08cb474606ab Mon Sep 17 00:00:00 2001 From: ultraelectronica Date: Tue, 30 Jun 2026 21:10:04 +0800 Subject: [PATCH 11/34] Add animated Latch logo widget and welcome screen tests --- lib/widgets/animated_latch_logo.dart | 122 +++++++++++++++++++++++++++ test/welcome_screen_test.dart | 54 ++++++++++++ 2 files changed, 176 insertions(+) create mode 100644 lib/widgets/animated_latch_logo.dart create mode 100644 test/welcome_screen_test.dart diff --git a/lib/widgets/animated_latch_logo.dart b/lib/widgets/animated_latch_logo.dart new file mode 100644 index 0000000..4c8cc3c --- /dev/null +++ b/lib/widgets/animated_latch_logo.dart @@ -0,0 +1,122 @@ +import 'package:flutter/material.dart'; + +/// Latch dot-matrix logo that assembles on entrance: each of the 21 dots +/// flies in from a radial-outward start and settles into its final position, +/// staggered so the logo blooms from the center outward. Geometry is taken +/// verbatim from assets/locker_logo_nobg.svg (viewBox 481x652, r=31.36). +class AnimatedLatchLogo extends StatelessWidget { + final Animation progress; + final double size; + final Color color; + + const AnimatedLatchLogo({ + super.key, + required this.progress, + this.size = 96, + required this.color, + }); + + @override + Widget build(BuildContext context) { + return CustomPaint( + size: Size.square(size), + painter: _LatchLogoPainter(progress: progress, color: color), + ); + } +} + +class _Dot { + final Offset target; + final Offset start; + final double delay; + final double duration; + const _Dot(this.target, this.start, this.delay, this.duration); +} + +class _LatchLogoPainter extends CustomPainter { + final Animation progress; + final Color color; + + _LatchLogoPainter({required this.progress, required this.color}) + : super(repaint: progress); + + // Raw circle centers (cx, cy) from the SVG. + static const List _raw = [ + Offset(31.3605, 527.102), + Offset(31.3605, 422.567), + Offset(31.3605, 318.031), + Offset(31.3605, 620.339), + Offset(80.8761, 224.795), + Offset(80.8761, 120.260), + Offset(397.517, 224.795), + Offset(397.517, 120.260), + Offset(135.604, 31.3606), + Offset(345.395, 31.3606), + Offset(241.151, 31.3606), + Offset(449.639, 527.102), + Offset(449.639, 422.567), + Offset(449.639, 318.031), + Offset(449.639, 620.339), + Offset(135.604, 318.031), + Offset(240.140, 318.031), + Offset(344.676, 318.031), + Offset(135.604, 620.339), + Offset(240.140, 620.339), + Offset(344.676, 620.339), + ]; + + static const double _viewW = 481.0; + static const double _viewH = 652.0; + static const double _dotR = 31.3607; + + // Dots staggered so the nearest to the logo centroid bloom first. + static final List<_Dot> dots = _buildDots(); + static List<_Dot> _buildDots() { + double cx = 0, cy = 0; + for (final o in _raw) { + cx += o.dx; + cy += o.dy; + } + cx /= _raw.length; + cy /= _raw.length; + final center = Offset(cx, cy); + + final order = List.generate(_raw.length, (i) => i) + ..sort((a, b) => + (_raw[a] - center).distance.compareTo((_raw[b] - center).distance)); + + const staggerSpan = 1.0; + const dotDur = 0.85; + final n = _raw.length; + final out = <_Dot>[]; + for (var k = 0; k < n; k++) { + final o = _raw[order[k]]; + final radial = (o - center) * 1.6; + final start = Offset(center.dx + radial.dx, center.dy + radial.dy); + final delay = (k / (n - 1)) * staggerSpan; + out.add(_Dot(o, start, delay, dotDur)); + } + return out; + } + + @override + void paint(Canvas canvas, Size size) { + final scale = size.height / _viewH; + final scaledW = _viewW * scale; + final xOff = (size.width - scaledW) / 2; + final r = _dotR * scale; + + final paint = Paint()..style = PaintingStyle.fill; + for (final d in dots) { + final local = ((progress.value - d.delay) / d.duration).clamp(0.0, 1.0); + final e = Curves.easeOut.transform(local); + final x = xOff + (d.start.dx + (d.target.dx - d.start.dx) * e) * scale; + final y = (d.start.dy + (d.target.dy - d.start.dy) * e) * scale; + paint.color = color.withValues(alpha: e); + canvas.drawCircle(Offset(x, y), r * (0.3 + 0.7 * e), paint); + } + } + + @override + bool shouldRepaint(covariant _LatchLogoPainter old) => color != old.color; +} diff --git a/test/welcome_screen_test.dart b/test/welcome_screen_test.dart new file mode 100644 index 0000000..14fb0f5 --- /dev/null +++ b/test/welcome_screen_test.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:locker/screens/auth_method_selection_screen.dart'; +import 'package:locker/widgets/animated_latch_logo.dart'; + +void main() { + Future pumpScreen( + WidgetTester tester, { + Brightness brightness = Brightness.light, + }) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(brightness: Brightness.light, useMaterial3: true), + darkTheme: ThemeData(brightness: Brightness.dark, useMaterial3: true), + themeMode: + brightness == Brightness.dark ? ThemeMode.dark : ThemeMode.light, + home: const AuthMethodSelectionScreen(), + ), + ); + } + + double titleOpacity(WidgetTester tester) => tester + .widgetList(find.ancestor( + of: find.text('Welcome to Latch'), matching: find.byType(Opacity))) + .first + .opacity; + + testWidgets('builds in light and dark, shows logo, title and method cards', + (tester) async { + await pumpScreen(tester); + await tester.pumpAndSettle(); + + expect(find.text('Welcome to Latch'), findsOneWidget); + expect(find.text('PIN'), findsOneWidget); + expect(find.text('Password'), findsOneWidget); + expect(find.text('Biometrics'), findsOneWidget); + expect(find.byType(AnimatedLatchLogo), findsOneWidget); + + await pumpScreen(tester, brightness: Brightness.dark); + await tester.pumpAndSettle(); + + expect(find.text('Welcome to Latch'), findsOneWidget); + expect(find.byType(AnimatedLatchLogo), findsOneWidget); + }); + + testWidgets('entrance animates the title from invisible to fully visible', + (tester) async { + await pumpScreen(tester); + expect(titleOpacity(tester), lessThan(0.2)); + + await tester.pumpAndSettle(); + expect(titleOpacity(tester), equals(1.0)); + }); +} From c420ec13354619e26d50d95dba987f208b98fec6 Mon Sep 17 00:00:00 2001 From: ultraelectronica Date: Tue, 30 Jun 2026 21:10:09 +0800 Subject: [PATCH 12/34] Reduce animation duration and stagger span in Latch logo --- lib/widgets/animated_latch_logo.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/widgets/animated_latch_logo.dart b/lib/widgets/animated_latch_logo.dart index 4c8cc3c..82be239 100644 --- a/lib/widgets/animated_latch_logo.dart +++ b/lib/widgets/animated_latch_logo.dart @@ -85,8 +85,8 @@ class _LatchLogoPainter extends CustomPainter { ..sort((a, b) => (_raw[a] - center).distance.compareTo((_raw[b] - center).distance)); - const staggerSpan = 1.0; - const dotDur = 0.85; + const staggerSpan = 0.50; + const dotDur = 0.25; final n = _raw.length; final out = <_Dot>[]; for (var k = 0; k < n; k++) { From 044f57398eeb94b071acb1ff721a4882596eaa9b Mon Sep 17 00:00:00 2001 From: ultraelectronica Date: Wed, 1 Jul 2026 00:45:08 +0800 Subject: [PATCH 13/34] Rename explorer view modes from sidebar/navigation to grid/list --- lib/providers/explorer_providers.dart | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/lib/providers/explorer_providers.dart b/lib/providers/explorer_providers.dart index 74677e5..e03beb4 100644 --- a/lib/providers/explorer_providers.dart +++ b/lib/providers/explorer_providers.dart @@ -6,22 +6,22 @@ import 'vault_providers.dart'; /// View modes supported by the vault file explorer enum ExplorerViewMode { - sidebar, - navigation; + grid, + list; String get displayName { switch (this) { - case ExplorerViewMode.sidebar: - return 'Sidebar Tree'; - case ExplorerViewMode.navigation: - return 'Grid Navigation'; + case ExplorerViewMode.grid: + return 'Grid View'; + case ExplorerViewMode.list: + return 'List View'; } } } /// Provider for current explorer view mode final explorerViewModeProvider = StateProvider((ref) { - return ExplorerViewMode.navigation; + return ExplorerViewMode.grid; }); /// Provider for the currently viewed folder ID (null represents the root vault folder) From a60b7325c6c13e177fda186a65283ebf63f8518d Mon Sep 17 00:00:00 2001 From: ultraelectronica Date: Wed, 1 Jul 2026 00:45:14 +0800 Subject: [PATCH 14/34] Add category filter bottom bar to gallery vault Replace TabBar with PageView and bottom navigation bar for file category filtering. Introduce VaultCategory enum with all, images, videos, songs, and docs tabs. Add shared preferences persistence for enabled categories and a customize bar option in the overflow menu. --- lib/screens/gallery_vault_screen.dart | 696 ++++++++++++++++++-------- 1 file changed, 483 insertions(+), 213 deletions(-) diff --git a/lib/screens/gallery_vault_screen.dart b/lib/screens/gallery_vault_screen.dart index 104370e..6e3be1d 100644 --- a/lib/screens/gallery_vault_screen.dart +++ b/lib/screens/gallery_vault_screen.dart @@ -8,6 +8,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:open_filex/open_filex.dart'; import 'package:path_provider/path_provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import '../utils/path_utils.dart'; import '../models/encryption_algorithm.dart'; import '../models/vaulted_file.dart'; @@ -43,6 +44,35 @@ import '../widgets/media_hold_action_sheet.dart'; import '../widgets/media_multi_select_action_sheet.dart'; import '../widgets/whats_new_bottom_sheet.dart'; +/// Categories shown in the gallery bottom bar. +enum VaultCategory { all, images, videos, songs, docs } + +extension VaultCategoryX on VaultCategory { + VaultedFileType? get filterType => switch (this) { + VaultCategory.all => null, + VaultCategory.images => VaultedFileType.image, + VaultCategory.videos => VaultedFileType.video, + VaultCategory.songs => VaultedFileType.song, + VaultCategory.docs => VaultedFileType.document, + }; + + IconData get icon => switch (this) { + VaultCategory.all => Icons.grid_view_rounded, + VaultCategory.images => Icons.image_outlined, + VaultCategory.videos => Icons.videocam_outlined, + VaultCategory.songs => Icons.music_note_outlined, + VaultCategory.docs => Icons.description_outlined, + }; + + String get label => switch (this) { + VaultCategory.all => 'All', + VaultCategory.images => 'Images', + VaultCategory.videos => 'Videos', + VaultCategory.songs => 'Songs', + VaultCategory.docs => 'Docs', + }; +} + /// Gallery vault screen - main screen after authentication class GalleryVaultScreen extends ConsumerStatefulWidget { const GalleryVaultScreen({super.key}); @@ -51,9 +81,10 @@ class GalleryVaultScreen extends ConsumerStatefulWidget { ConsumerState createState() => _GalleryVaultScreenState(); } -class _GalleryVaultScreenState extends ConsumerState - with SingleTickerProviderStateMixin { - late TabController _tabController; +class _GalleryVaultScreenState extends ConsumerState { + VaultCategory _selectedCategory = VaultCategory.all; + List _enabledCategories = VaultCategory.values; + late final PageController _pageController; final FileImportService _importService = FileImportService.instance; final Map _selectionTileKeys = {}; @@ -75,13 +106,14 @@ class _GalleryVaultScreenState extends ConsumerState @override void initState() { super.initState(); - _tabController = TabController(length: 5, vsync: this); + _pageController = PageController(); + _loadEnabledCategories(); _initializeVault(); } @override void dispose() { - _tabController.dispose(); + _pageController.dispose(); _searchController.dispose(); super.dispose(); } @@ -131,23 +163,28 @@ class _GalleryVaultScreenState extends ConsumerState // Permission warning banner for All Files Access const PermissionWarningBanner(), if (_isImporting) _buildImportProgress(), - if (_isSearching) _buildSearchBar(), - _buildTabBar(filesAsync), + AnimatedCrossFade( + duration: const Duration(milliseconds: 300), + crossFadeState: _isSearching + ? CrossFadeState.showFirst + : CrossFadeState.showSecond, + firstChild: _buildSearchBar(), + secondChild: const SizedBox(height: 0, width: double.infinity), + sizeCurve: Curves.easeInOut, + ), Expanded( - child: TabBarView( - controller: _tabController, - children: [ - _buildFileGrid(null, filesAsync), - _buildFileGrid(VaultedFileType.image, filesAsync), - _buildFileGrid(VaultedFileType.video, filesAsync), - _buildFileGrid(VaultedFileType.song, filesAsync), - _buildFileGrid(VaultedFileType.document, filesAsync), - ], + child: PageView( + controller: _pageController, + onPageChanged: (index) => + setState(() => _selectedCategory = _enabledCategories[index]), + children: _enabledCategories + .map((c) => _buildFileGrid(c.filterType, filesAsync)) + .toList(), ), ), ], ), - floatingActionButton: isSelectionMode ? null : _buildFAB(), + bottomNavigationBar: _buildBottomBar(isSelectionMode), drawer: _buildDrawer(), ); } @@ -231,6 +268,9 @@ class _GalleryVaultScreenState extends ConsumerState case 'settings': _openSettingsScreen(); break; + case 'customize_bar': + _showCustomizeBarSheet(); + break; case 'refresh': ref.read(vaultNotifierProvider.notifier).refresh(); break; @@ -267,6 +307,16 @@ class _GalleryVaultScreenState extends ConsumerState ], ), ), + const PopupMenuItem( + value: 'customize_bar', + child: Row( + children: [ + Icon(Icons.tune, size: 20), + SizedBox(width: 12), + Text('Customize Bar'), + ], + ), + ), const PopupMenuItem( value: 'settings', child: Row( @@ -296,7 +346,6 @@ class _GalleryVaultScreenState extends ConsumerState Widget _buildSearchBar() { return Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - color: context.backgroundSecondary, child: TextField( controller: _searchController, decoration: InputDecoration( @@ -311,7 +360,7 @@ class _GalleryVaultScreenState extends ConsumerState borderSide: BorderSide.none, ), filled: true, - fillColor: AppColors.lightBackground, + fillColor: context.backgroundSecondary, contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), ), @@ -360,108 +409,310 @@ class _GalleryVaultScreenState extends ConsumerState ); } - Widget _buildTabBar(AsyncValue> filesAsync) { - final files = filesAsync.value ?? []; - - // Compute counts once to avoid O(n) scans on every build - int imageCount = 0; - int videoCount = 0; - int songCount = 0; - int docCount = 0; - for (final f in files) { - switch (f.type) { - case VaultedFileType.image: - imageCount++; - break; - case VaultedFileType.video: - videoCount++; - break; - case VaultedFileType.song: - songCount++; - break; - case VaultedFileType.document: - case VaultedFileType.other: - docCount++; - break; - } - } - + Widget _buildBottomBar(bool isSelectionMode) { return Container( decoration: BoxDecoration( color: context.backgroundColor, border: Border( - bottom: BorderSide(color: AppColors.lightDivider, width: 1), + top: BorderSide(color: context.dividerColor, width: 1), ), ), - child: TabBar( - controller: _tabController, - labelColor: context.accentColor, - unselectedLabelColor: AppColors.lightTextTertiary, - indicatorColor: context.accentColor, - indicatorWeight: 3, - isScrollable: true, - labelStyle: const TextStyle( - fontFamily: 'ProductSans', - fontWeight: FontWeight.w600, - fontSize: 14, - ), - unselectedLabelStyle: const TextStyle( - fontFamily: 'ProductSans', - fontWeight: FontWeight.normal, - fontSize: 14, - ), - tabs: [ - Tab( + child: SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.only(top: 10), + child: SizedBox( + height: 64, child: Row( - mainAxisSize: MainAxisSize.min, children: [ - const Icon(Icons.grid_view_rounded, size: 18), - const SizedBox(width: 6), - Text('All (${files.length})'), + if (!isSelectionMode) _buildImportBarItem(), + ..._enabledCategories.map(_buildCategoryItem), ], ), ), - Tab( - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.image_outlined, size: 18), - const SizedBox(width: 6), - Text('Images ($imageCount)'), - ], - ), + ), + ), + ); + } + + Widget _buildImportBarItem() { + return Expanded( + child: Tooltip( + message: 'Import files', + child: InkWell( + onTap: _showImportDialog, + customBorder: const CircleBorder(), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: LinearGradient( + colors: [ + context.accentColor, + context.accentColor.withValues(alpha: 0.8), + ], + ), + ), + child: const Icon(Icons.add, color: Colors.white, size: 22), + ), + const SizedBox(height: 2), + Text( + 'Import', + style: TextStyle( + fontFamily: 'ProductSans', + fontSize: 11, + color: context.textSecondary, + ), + ), + ], ), - Tab( - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.videocam_outlined, size: 18), - const SizedBox(width: 6), - Text('Videos ($videoCount)'), - ], - ), + ), + ), + ); + } + + Widget _buildCategoryItem(VaultCategory category) { + final selected = category == _selectedCategory; + final color = selected ? context.accentColor : AppColors.lightTextTertiary; + return Expanded( + child: Tooltip( + message: category.label, + child: InkWell( + onTap: () { + final index = _enabledCategories.indexOf(category); + _pageController.animateToPage( + index, + duration: const Duration(milliseconds: 220), + curve: Curves.easeOutCubic, + ); + }, + customBorder: const CircleBorder(), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(category.icon, size: 24, color: color), + const SizedBox(height: 2), + Text( + category.label, + style: TextStyle( + fontFamily: 'ProductSans', + fontSize: 11, + fontWeight: selected ? FontWeight.w600 : FontWeight.normal, + color: color, + ), + ), + ], ), - Tab( - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.music_note_outlined, size: 18), - const SizedBox(width: 6), - Text('Songs ($songCount)'), - ], + ), + ), + ); + } + + static const _enabledCategoriesKey = 'gallery_bottom_bar_categories'; + + Future _loadEnabledCategories() async { + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getStringList(_enabledCategoriesKey); + if (raw == null || raw.isEmpty) return; + final parsed = []; + for (final name in raw) { + final match = VaultCategory.values.firstWhere( + (e) => e.name == name, + orElse: () => VaultCategory.all, + ); + if (!parsed.contains(match)) parsed.add(match); + } + if (parsed.isEmpty || !mounted) return; + setState(() { + _enabledCategories = parsed; + if (!_enabledCategories.contains(_selectedCategory)) { + _selectedCategory = _enabledCategories.first; + } + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + final index = _enabledCategories.indexOf(_selectedCategory); + if (index != -1 && _pageController.hasClients) { + _pageController.jumpToPage(index); + } + }); + } + + Future _persistEnabledCategories() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setStringList( + _enabledCategoriesKey, + _enabledCategories.map((c) => c.name).toList(), + ); + } + + void _showCustomizeBarSheet() { + var order = [ + ..._enabledCategories, + ...VaultCategory.values.where((c) => !_enabledCategories.contains(c)), + ]; + var enabled = Set.from(_enabledCategories); + + void apply(StateSetter setSheetState, void Function() mutate) { + setSheetState(mutate); + final newEnabled = order.where((c) => enabled.contains(c)).toList(); + setState(() { + _enabledCategories = newEnabled; + if (!_enabledCategories.contains(_selectedCategory)) { + _selectedCategory = _enabledCategories.contains(VaultCategory.all) + ? VaultCategory.all + : _enabledCategories.first; + } + }); + _persistEnabledCategories(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + final index = _enabledCategories.indexOf(_selectedCategory); + if (index != -1 && _pageController.hasClients) { + _pageController.jumpToPage(index); + } + }); + } + + showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + isScrollControlled: true, + builder: (sheetContext) => StatefulBuilder( + builder: (context, setSheetState) { + return Container( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.7, ), - ), - Tab( - child: Row( + decoration: BoxDecoration( + color: context.backgroundColor, + borderRadius: + const BorderRadius.vertical(top: Radius.circular(24)), + ), + child: Column( mainAxisSize: MainAxisSize.min, children: [ - const Icon(Icons.description_outlined, size: 18), - const SizedBox(width: 6), - Text('Docs ($docCount)'), + Container( + margin: const EdgeInsets.only(top: 12), + width: 40, + height: 4, + decoration: BoxDecoration( + color: context.borderColor, + borderRadius: BorderRadius.circular(2), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 4), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + 'Customize Bottom Bar', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: context.textPrimary, + fontFamily: 'ProductSans', + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 8), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + 'Drag to reorder. Toggle to show or hide.', + style: TextStyle( + fontFamily: 'ProductSans', + fontSize: 13, + color: context.textSecondary, + ), + ), + ), + ), + Flexible( + child: ReorderableListView.builder( + buildDefaultDragHandles: false, + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: order.length, + onReorder: (oldIndex, newIndex) { + apply(setSheetState, () { + final to = newIndex > oldIndex ? newIndex - 1 : newIndex; + final item = order.removeAt(oldIndex); + order.insert(to, item); + }); + }, + itemBuilder: (context, i) { + final cat = order[i]; + final isEnabled = enabled.contains(cat); + return SwitchListTile( + key: ValueKey(cat), + value: isEnabled, + onChanged: (v) { + apply(setSheetState, () { + if (v) { + enabled.add(cat); + } else if (enabled.length > 1) { + enabled.remove(cat); + } + }); + }, + activeThumbColor: context.accentColor, + secondary: Row( + mainAxisSize: MainAxisSize.min, + children: [ + ReorderableDragStartListener( + index: i, + child: Icon(Icons.drag_indicator, + color: context.textTertiary), + ), + Icon(cat.icon, color: context.accentColor), + ], + ), + title: Text( + cat.label, + style: const TextStyle(fontFamily: 'ProductSans'), + ), + contentPadding: + const EdgeInsets.symmetric(horizontal: 20), + ); + }, + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 16), + child: SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: () => Navigator.pop(context), + style: ElevatedButton.styleFrom( + backgroundColor: context.accentColor, + foregroundColor: Colors.white, + elevation: 0, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: const Text( + 'Done', + style: TextStyle( + fontFamily: 'ProductSans', + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ), + SizedBox(height: MediaQuery.of(context).padding.bottom), ], ), - ), - ], + ); + }, ), ); } @@ -1004,27 +1255,10 @@ class _GalleryVaultScreenState extends ConsumerState return Container( color: color.withValues(alpha: 0.1), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(icon, size: 36, color: color), - const SizedBox(height: 4), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Text( - file.originalName, - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.bold, - color: color, - fontFamily: 'ProductSans', - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.center, - ), - ), - ], + // ponytail: filename is drawn once by the tile's bottom overlay bar; + // placeholder is icon-only like image/video thumbnails. + child: Center( + child: Icon(icon, size: 36, color: color), ), ); } @@ -1098,13 +1332,7 @@ class _GalleryVaultScreenState extends ConsumerState List _getVisibleFiles() { final allFiles = ref.read(vaultNotifierProvider).value ?? []; - final filterType = switch (_tabController.index) { - 1 => VaultedFileType.image, - 2 => VaultedFileType.video, - 3 => VaultedFileType.song, - 4 => VaultedFileType.document, - _ => null, - }; + final filterType = _selectedCategory.filterType; List files; if (filterType == null) { @@ -1165,34 +1393,43 @@ class _GalleryVaultScreenState extends ConsumerState ); } - void _openNoteFromVault(String noteId) { - final notesAsync = ref.read(notesNotifierProvider); - notesAsync.whenData((notes) { - final note = notes.where((n) => n.id == noteId).firstOrNull; - if (note != null && mounted) { - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => NoteEditorScreen(note: note), - ), - ); - } - }); + Future _openNoteFromVault(String noteId) async { + var notesAsync = ref.read(notesNotifierProvider); + // ponytail: first read of a cold provider returns loading; reload + re-read + // only in that case so the hot path stays a single cached read. + if (notesAsync.isLoading) { + await ref.read(notesNotifierProvider.notifier).loadNotes(); + notesAsync = ref.read(notesNotifierProvider); + } + if (!mounted) return; + final note = notesAsync.value?.where((n) => n.id == noteId).firstOrNull; + if (note != null) { + await Navigator.push( + context, + MaterialPageRoute( + builder: (_) => NoteEditorScreen(note: note), + ), + ); + } } - void _openPasswordFromVault(String passwordId) { - final passwordsAsync = ref.read(passwordsNotifierProvider); - passwordsAsync.whenData((passwords) { - final entry = passwords.where((p) => p.id == passwordId).firstOrNull; - if (entry != null && mounted) { - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => PasswordEditorScreen(entry: entry), - ), - ); - } - }); + Future _openPasswordFromVault(String passwordId) async { + var passwordsAsync = ref.read(passwordsNotifierProvider); + if (passwordsAsync.isLoading) { + await ref.read(passwordsNotifierProvider.notifier).loadPasswords(); + passwordsAsync = ref.read(passwordsNotifierProvider); + } + if (!mounted) return; + final entry = + passwordsAsync.value?.where((p) => p.id == passwordId).firstOrNull; + if (entry != null) { + await Navigator.push( + context, + MaterialPageRoute( + builder: (_) => PasswordEditorScreen(entry: entry), + ), + ); + } } /// Show options for files that don't have a preview @@ -1779,42 +2016,6 @@ class _GalleryVaultScreenState extends ConsumerState ); } - Widget _buildFAB() { - return Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16), - gradient: LinearGradient( - colors: [ - context.accentColor, - context.accentColor.withValues(alpha: 0.8), - ], - ), - boxShadow: [ - BoxShadow( - color: context.accentColor.withValues(alpha: 0.4), - blurRadius: 12, - offset: const Offset(0, 4), - ), - ], - ), - child: FloatingActionButton.extended( - onPressed: _showImportDialog, - backgroundColor: Colors.transparent, - elevation: 0, - icon: const Icon(Icons.add, color: Colors.white), - label: const Text( - 'Import', - style: TextStyle( - fontFamily: 'ProductSans', - fontWeight: FontWeight.w600, - color: Colors.white, - fontSize: 15, - ), - ), - ), - ); - } - Widget _buildDrawer() { return Drawer( backgroundColor: Colors.transparent, @@ -1846,9 +2047,13 @@ class _GalleryVaultScreenState extends ConsumerState child: ListView( padding: EdgeInsets.zero, children: [ - _buildDrawerItem( + _buildDrawerSection('Library'), + _buildCountedDrawerItem( icon: Icons.folder_outlined, title: 'Albums', + countOf: (ref) => ref + .watch(albumsProvider) + .maybeWhen(data: (l) => l.length, orElse: () => null), onTap: () { Navigator.pop(context); Navigator.push( @@ -1858,9 +2063,12 @@ class _GalleryVaultScreenState extends ConsumerState ); }, ), - _buildDrawerItem( + _buildCountedDrawerItem( icon: Icons.folder_copy_outlined, title: 'Folders', + countOf: (ref) => ref + .watch(foldersProvider) + .maybeWhen(data: (l) => l.length, orElse: () => null), onTap: () { Navigator.pop(context); Navigator.push( @@ -1870,9 +2078,16 @@ class _GalleryVaultScreenState extends ConsumerState ); }, ), - _buildDrawerItem( + _buildCountedDrawerItem( icon: Icons.explore_outlined, title: 'File Explorer', + countOf: (ref) => ref + .watch(fileCountsProvider) + .maybeWhen( + data: (m) => + m.values.fold(0, (s, c) => s + c), + orElse: () => null, + ), onTap: () { Navigator.pop(context); Navigator.push( @@ -1883,9 +2098,12 @@ class _GalleryVaultScreenState extends ConsumerState ); }, ), - _buildDrawerItem( + _buildCountedDrawerItem( icon: Icons.favorite_outline, title: 'Favorites', + countOf: (ref) => ref + .watch(favoriteFilesProvider) + .maybeWhen(data: (l) => l.length, orElse: () => null), onTap: () { Navigator.pop(context); Navigator.push( @@ -1895,9 +2113,12 @@ class _GalleryVaultScreenState extends ConsumerState ); }, ), - _buildDrawerItem( + _buildCountedDrawerItem( icon: Icons.label_outline, title: 'Tags', + countOf: (ref) => ref + .watch(tagsProvider) + .maybeWhen(data: (l) => l.length, orElse: () => null), onTap: () { Navigator.pop(context); Navigator.push( @@ -1907,19 +2128,11 @@ class _GalleryVaultScreenState extends ConsumerState ); }, ), - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16, vertical: 8), - child: Divider( - color: context.isDarkMode - ? Colors.white.withValues(alpha: 0.1) - : Colors.black.withValues(alpha: 0.1), - thickness: 1, - ), - ), + _buildDrawerSection('Security'), _buildDrawerItem( icon: Icons.security, title: 'Security Settings', + showChevron: true, onTap: () { Navigator.pop(context); _openSettingsScreen(); @@ -1929,6 +2142,7 @@ class _GalleryVaultScreenState extends ConsumerState icon: Icons.shield_outlined, title: 'Decoy Mode', subtitle: 'Set up fake vault', + showChevron: true, onTap: () { Navigator.pop(context); _showDecoyModeSheet(); @@ -2025,10 +2239,47 @@ class _GalleryVaultScreenState extends ConsumerState ); } + Widget _buildDrawerSection(String title) { + return Padding( + padding: const EdgeInsets.fromLTRB(28, 20, 16, 8), + child: Text( + title.toUpperCase(), + style: TextStyle( + fontFamily: 'ProductSans', + fontSize: 11, + fontWeight: FontWeight.w700, + letterSpacing: 1.2, + color: context.textTertiary, + ), + ), + ); + } + + Widget _buildCountedDrawerItem({ + required IconData icon, + required String title, + required int? Function(WidgetRef ref) countOf, + required VoidCallback onTap, + }) { + return Consumer( + builder: (context, ref, _) { + final count = countOf(ref); + return _buildDrawerItem( + icon: icon, + title: title, + badge: count?.toString(), + onTap: onTap, + ); + }, + ); + } + Widget _buildDrawerItem({ required IconData icon, required String title, String? subtitle, + String? badge, + bool showChevron = false, required VoidCallback onTap, }) { return Padding( @@ -2087,11 +2338,30 @@ class _GalleryVaultScreenState extends ConsumerState ], ), ), - Icon( - Icons.chevron_right, - color: context.textTertiary, - size: 20, - ), + if (badge != null) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: context.accentColor.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + badge, + style: TextStyle( + fontFamily: 'ProductSans', + fontSize: 12, + fontWeight: FontWeight.w600, + color: context.accentColor, + ), + ), + ) + else if (showChevron) + Icon( + Icons.chevron_right, + color: context.textTertiary, + size: 20, + ), ], ), ), From 9b74e6eaab7e2cd890c483214697f3ce87f080cb Mon Sep 17 00:00:00 2001 From: ultraelectronica Date: Wed, 1 Jul 2026 00:45:19 +0800 Subject: [PATCH 15/34] Remove sidebar view and resizer widget --- lib/screens/vault_explorer_screen.dart | 180 ++----------------------- 1 file changed, 12 insertions(+), 168 deletions(-) diff --git a/lib/screens/vault_explorer_screen.dart b/lib/screens/vault_explorer_screen.dart index 11a28aa..e258e9c 100644 --- a/lib/screens/vault_explorer_screen.dart +++ b/lib/screens/vault_explorer_screen.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../utils/path_utils.dart'; import '../models/album.dart'; @@ -10,7 +9,6 @@ import '../providers/explorer_providers.dart'; import '../services/file_import_service.dart'; import '../themes/app_colors.dart'; import '../utils/toast_utils.dart'; -import '../widgets/folder_tree_widget.dart'; import '../widgets/folder_breadcrumb_widget.dart'; import '../widgets/explorer_file_grid.dart'; import '../widgets/explorer_toolbar.dart'; @@ -31,11 +29,6 @@ class _VaultExplorerScreenState extends ConsumerState { final TextEditingController _nameController = TextEditingController(); final TextEditingController _descController = TextEditingController(); - static const double _sidebarMinWidth = 180; - static const double _sidebarMaxWidthFraction = 0.7; - static const double _sidebarDefaultFraction = 0.55; - double? _sidebarWidth; - @override void dispose() { _nameController.dispose(); @@ -47,16 +40,16 @@ class _VaultExplorerScreenState extends ConsumerState { Widget build(BuildContext context) { final isSelectionMode = ref.watch(isSelectionModeProvider); final selectedFiles = ref.watch(selectedFilesProvider); - final viewMode = ref.watch(explorerViewModeProvider); final currentFolderAsync = ref.watch(explorerCurrentFolderProvider); return Scaffold( backgroundColor: Theme.of(context).scaffoldBackgroundColor, appBar: _buildAppBar(currentFolderAsync, isSelectionMode, selectedFiles), - body: _buildBody(viewMode), + body: _buildBody(), floatingActionButton: isSelectionMode ? null : FloatingActionButton.extended( + elevation: 0, onPressed: () => _showCreateFolderDialog(), backgroundColor: context.accentColor, icon: const Icon(Icons.create_new_folder, color: Colors.white), @@ -158,78 +151,17 @@ class _VaultExplorerScreenState extends ConsumerState { ); } - Widget _buildBody(ExplorerViewMode viewMode) { - final isSidebar = viewMode == ExplorerViewMode.sidebar; - - if (isSidebar) { - final screenWidth = MediaQuery.of(context).size.width; - _sidebarWidth ??= screenWidth * _sidebarDefaultFraction; - } - - final sidebarWidth = _sidebarWidth ?? 240; - - final child = isSidebar - ? Row( - key: const ValueKey('sidebar-layout'), - children: [ - SizedBox( - width: sidebarWidth, - child: FolderTreeWidget( - onFolderLongPress: (folder) => _showFolderOptions(folder), - ), - ), - _SidebarResizer( - width: sidebarWidth, - minWidth: _sidebarMinWidth, - maxWidthFraction: _sidebarMaxWidthFraction, - onResize: (newWidth) { - setState(() { - _sidebarWidth = newWidth; - }); - }, - ), - const VerticalDivider(width: 1, thickness: 1), - Expanded( - child: Column( - children: [ - const ExplorerToolbar(), - Expanded( - child: ExplorerFileGrid( - onFolderLongPress: (folder) => _showFolderOptions(folder), - ), - ), - ], - ), - ), - ], - ) - : Column( - key: const ValueKey('navigation-layout'), - children: [ - const FolderBreadcrumbWidget(), - const ExplorerToolbar(), - Expanded( - child: ExplorerFileGrid( - onFolderLongPress: (folder) => _showFolderOptions(folder), - ), - ), - ], - ); - - return AnimatedSwitcher( - duration: const Duration(milliseconds: 300), - switchInCurve: Curves.easeInOut, - switchOutCurve: Curves.easeInOut, - transitionBuilder: (child, animation) { - return FadeTransition( - opacity: animation, - child: ScaleTransition( - scale: Tween(begin: 0.98, end: 1.0).animate(animation), - child: child, + Widget _buildBody() { + return Column( + children: [ + const FolderBreadcrumbWidget(), + const ExplorerToolbar(), + Expanded( + child: ExplorerFileGrid( + onFolderLongPress: (folder) => _showFolderOptions(folder), ), - ); - }, - child: child, + ), + ], ); } @@ -1166,91 +1098,3 @@ class _VaultExplorerScreenState extends ConsumerState { ); } } - -class _SidebarResizer extends StatefulWidget { - const _SidebarResizer({ - required this.width, - required this.minWidth, - required this.maxWidthFraction, - required this.onResize, - }); - - final double width; - final double minWidth; - final double maxWidthFraction; - final ValueChanged onResize; - - @override - State<_SidebarResizer> createState() => _SidebarResizerState(); -} - -class _SidebarResizerState extends State<_SidebarResizer> { - bool _isDragging = false; - - static const double _grabWidth = 28; - - @override - Widget build(BuildContext context) { - final dividerColor = _isDragging - ? Theme.of(context).colorScheme.primary - : Theme.of(context).dividerColor; - final iconColor = _isDragging - ? Theme.of(context).colorScheme.primary - : Theme.of(context).hintColor; - - return MouseRegion( - cursor: SystemMouseCursors.resizeColumn, - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onHorizontalDragStart: (_) { - HapticFeedback.mediumImpact(); - setState(() => _isDragging = true); - }, - onHorizontalDragUpdate: (details) { - final screenWidth = MediaQuery.of(context).size.width; - final maxAllowed = screenWidth * widget.maxWidthFraction; - final newWidth = (widget.width + details.delta.dx) - .clamp(widget.minWidth, maxAllowed); - widget.onResize(newWidth); - }, - onHorizontalDragEnd: (_) { - HapticFeedback.lightImpact(); - setState(() => _isDragging = false); - }, - onHorizontalDragCancel: () { - setState(() => _isDragging = false); - }, - child: SizedBox( - width: _grabWidth, - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Expanded( - child: Container( - width: 2, - color: dividerColor, - ), - ), - Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: Icon( - Icons.drag_indicator, - size: 16, - color: iconColor, - ), - ), - Expanded( - child: Container( - width: 2, - color: dividerColor, - ), - ), - ], - ), - ), - ), - ), - ); - } -} From 8a85a9300968ab421c8b3a43a323b27ba2ea10b4 Mon Sep 17 00:00:00 2001 From: ultraelectronica Date: Wed, 1 Jul 2026 00:45:25 +0800 Subject: [PATCH 16/34] Add list view mode to explorer file grid Introduce a new list view alongside the existing grid view for displaying folders and files. The view mode is determined by the `ExplorerViewMode.list` enum value, allowing users to switch between a compact grid layout and a detailed list layout. --- lib/widgets/explorer_file_grid.dart | 285 +++++++++++++++++++++++----- 1 file changed, 242 insertions(+), 43 deletions(-) diff --git a/lib/widgets/explorer_file_grid.dart b/lib/widgets/explorer_file_grid.dart index 3a4e337..2df11f1 100644 --- a/lib/widgets/explorer_file_grid.dart +++ b/lib/widgets/explorer_file_grid.dart @@ -61,11 +61,8 @@ class ExplorerFileGrid extends ConsumerWidget { ), ), data: (files) { - // In Sidebar Mode, we only show files in the grid - final showFoldersInGrid = - viewMode == ExplorerViewMode.navigation; - final displayFolders = - showFoldersInGrid ? folders : []; + final isListView = viewMode == ExplorerViewMode.list; + final displayFolders = folders; if (displayFolders.isEmpty && files.isEmpty) { return _buildEmptyState(context); @@ -73,8 +70,7 @@ class ExplorerFileGrid extends ConsumerWidget { // Combine folders and files into a single mixed list index final folderCount = displayFolders.length; - final fileCount = files.length; - final totalItems = folderCount + fileCount; + final totalItems = folderCount + files.length; return RefreshIndicator( onRefresh: () async { @@ -83,43 +79,76 @@ class ExplorerFileGrid extends ConsumerWidget { ref.invalidate(unfiledFilesProvider); }, color: context.accentColor, - child: GridView.builder( - padding: const EdgeInsets.all(12), - gridDelegate: ResponsiveGridDelegate.responsive( - context, - compact: 3, - medium: 4, - expanded: 6, - crossAxisSpacing: 8, - mainAxisSpacing: 8, - childAspectRatio: 1, - ), - itemCount: totalItems, - itemBuilder: (context, index) { - if (index < folderCount) { - // Render Folder Card - return _buildFolderGridItem( - context, - ref, - displayFolders[index], - ); - } else { - // Render File Card - final fileIndex = index - folderCount; - final file = files[fileIndex]; - final isSelected = selectedFiles.contains(file.id); + child: isListView + ? ListView.builder( + padding: const EdgeInsets.all(12), + itemCount: totalItems, + itemBuilder: (context, index) { + if (index < folderCount) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: _buildFolderListItem( + context, + ref, + displayFolders[index], + ), + ); + } + final file = files[index - folderCount]; + final isSelected = + selectedFiles.contains(file.id); + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: _buildFileListItem( + context, + ref, + file, + files, + isSelected, + isSelectionMode, + ), + ); + }, + ) + : GridView.builder( + padding: const EdgeInsets.all(12), + gridDelegate: + ResponsiveGridDelegate.responsive( + context, + compact: 3, + medium: 4, + expanded: 6, + crossAxisSpacing: 8, + mainAxisSpacing: 8, + childAspectRatio: 1, + ), + itemCount: totalItems, + itemBuilder: (context, index) { + if (index < folderCount) { + // Render Folder Card + return _buildFolderGridItem( + context, + ref, + displayFolders[index], + ); + } else { + // Render File Card + final fileIndex = index - folderCount; + final file = files[fileIndex]; + final isSelected = + selectedFiles.contains(file.id); - return _buildFileGridItem( - context, - ref, - file, - files, // Pass displaying files list for the viewer carousel - isSelected, - isSelectionMode, - ); - } - }, - ), + return _buildFileGridItem( + context, + ref, + file, + files, // Pass displaying files list for the viewer carousel + isSelected, + isSelectionMode, + ); + } + }, + ), ); }, ); @@ -283,6 +312,176 @@ class ExplorerFileGrid extends ConsumerWidget { ); } + Widget _buildFolderListItem( + BuildContext context, + WidgetRef ref, + VaultFolder folder, + ) { + return GestureDetector( + onTap: () { + ref.read(explorerCurrentFolderIdProvider.notifier).state = folder.id; + }, + onLongPress: + onFolderLongPress != null ? () => onFolderLongPress!(folder) : null, + child: Container( + decoration: BoxDecoration( + color: context.backgroundSecondary, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: context.borderColor.withValues(alpha: 0.5), + width: 1, + ), + ), + child: ListTile( + contentPadding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + leading: Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: context.accentColor.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(10), + ), + child: Icon(Icons.folder, color: context.accentColor, size: 24), + ), + title: Text( + folder.name, + style: TextStyle( + fontFamily: 'ProductSans', + fontSize: 14, + fontWeight: FontWeight.w600, + color: context.textPrimary, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: Text( + '${folder.fileCount} file${folder.fileCount == 1 ? '' : 's'}', + style: TextStyle( + fontFamily: 'ProductSans', + fontSize: 11, + color: context.textSecondary, + ), + ), + trailing: Icon(Icons.chevron_right, color: context.textTertiary), + ), + ), + ); + } + + Widget _buildFileListItem( + BuildContext context, + WidgetRef ref, + VaultedFile file, + List allFiles, + bool isSelected, + bool isSelectionMode, + ) { + return GestureDetector( + onTap: () { + if (isSelectionMode) { + _toggleSelection(ref, file.id); + } else { + _showMediaHoldActionSheet(context, ref, file, allFiles); + } + }, + onLongPress: () { + if (!isSelectionMode) { + HapticFeedback.mediumImpact(); + _showMediaHoldActionSheet(context, ref, file, allFiles); + } + }, + child: Container( + decoration: BoxDecoration( + color: isSelected + ? context.accentColor.withValues(alpha: 0.08) + : context.backgroundSecondary, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isSelected + ? context.accentColor + : context.borderColor.withValues(alpha: 0.5), + width: isSelected ? 2 : 1, + ), + ), + child: ListTile( + contentPadding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + leading: Stack( + clipBehavior: Clip.none, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: SizedBox( + width: 44, + height: 44, + child: _buildFileThumbnail(file, context), + ), + ), + if (isSelectionMode) + Positioned( + bottom: -2, + right: -2, + child: Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isSelected ? context.accentColor : Colors.white, + border: Border.all( + color: + isSelected ? context.accentColor : Colors.black45, + width: 2, + ), + ), + padding: const EdgeInsets.all(2), + child: isSelected + ? const Icon(Icons.check, + size: 12, color: Colors.white) + : const SizedBox(width: 12, height: 12), + ), + ), + ], + ), + title: Row( + children: [ + if (file.isFavorite) + Padding( + padding: const EdgeInsets.only(right: 4), + child: Icon(Icons.favorite, size: 14, color: Colors.red), + ), + Expanded( + child: Text( + file.originalName, + style: TextStyle( + fontFamily: 'ProductSans', + fontSize: 14, + fontWeight: FontWeight.w600, + color: context.textPrimary, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + subtitle: Text( + '${file.type.displayName} \u2022 ${file.formattedSize} \u2022 ${file.formattedDateAdded}', + style: TextStyle( + fontFamily: 'ProductSans', + fontSize: 11, + color: context.textSecondary, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + trailing: file.isVideo + ? Icon(Icons.play_circle_outline, + color: context.textSecondary, size: 22) + : null, + ), + ), + ); + } + Widget _buildFileGridItem( BuildContext context, WidgetRef ref, From a85002dd7f09ba69e3930d7ce9727ff548a849b9 Mon Sep 17 00:00:00 2001 From: ultraelectronica Date: Wed, 1 Jul 2026 00:45:29 +0800 Subject: [PATCH 17/34] Rename explorer view modes from navigation/sidebar to list/grid --- lib/widgets/explorer_toolbar.dart | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/lib/widgets/explorer_toolbar.dart b/lib/widgets/explorer_toolbar.dart index 25b81c2..5c73fe1 100644 --- a/lib/widgets/explorer_toolbar.dart +++ b/lib/widgets/explorer_toolbar.dart @@ -34,7 +34,7 @@ class ExplorerToolbar extends ConsumerWidget { Row( mainAxisSize: MainAxisSize.min, children: [ - if (currentFolderId != null && viewMode == ExplorerViewMode.navigation) + if (currentFolderId != null && viewMode == ExplorerViewMode.list) currentFolderAsync.when( loading: () => const SizedBox.shrink(), error: (_, __) => const SizedBox.shrink(), @@ -72,19 +72,19 @@ class ExplorerToolbar extends ConsumerWidget { // View Mode Toggle IconButton( icon: Icon( - viewMode == ExplorerViewMode.navigation - ? Icons.account_tree_outlined - : Icons.grid_view_outlined, + viewMode == ExplorerViewMode.list + ? Icons.grid_view_outlined + : Icons.view_list_outlined, size: 20, color: context.textSecondary, ), - tooltip: viewMode == ExplorerViewMode.navigation - ? 'Switch to Sidebar Tree' - : 'Switch to Grid Navigation', + tooltip: viewMode == ExplorerViewMode.list + ? 'Switch to Grid View' + : 'Switch to List View', onPressed: () { - final newMode = viewMode == ExplorerViewMode.navigation - ? ExplorerViewMode.sidebar - : ExplorerViewMode.navigation; + final newMode = viewMode == ExplorerViewMode.list + ? ExplorerViewMode.grid + : ExplorerViewMode.list; ref.read(explorerViewModeProvider.notifier).state = newMode; }, ), From 0e0b7c832725a7484f66eb2cf9a6808db7ab93d9 Mon Sep 17 00:00:00 2001 From: ultraelectronica Date: Wed, 1 Jul 2026 00:45:35 +0800 Subject: [PATCH 18/34] Remove FAB elevation across screens --- lib/screens/album_detail_screen.dart | 1 + lib/screens/albums_screen.dart | 1 + lib/screens/folder_detail_screen.dart | 1 + lib/screens/folders_screen.dart | 1 + lib/screens/note_folders_screen.dart | 1 + lib/screens/note_list_screen.dart | 1 + lib/screens/password_list_screen.dart | 1 + lib/screens/tags_screen.dart | 2 ++ lib/themes/app_theme.dart | 2 +- 9 files changed, 10 insertions(+), 1 deletion(-) diff --git a/lib/screens/album_detail_screen.dart b/lib/screens/album_detail_screen.dart index 175b7c9..67a897d 100644 --- a/lib/screens/album_detail_screen.dart +++ b/lib/screens/album_detail_screen.dart @@ -50,6 +50,7 @@ class _AlbumDetailScreenState extends ConsumerState { floatingActionButton: _isSelectionMode ? null : FloatingActionButton.extended( + elevation: 0, onPressed: _showAddFilesSheet, backgroundColor: context.accentColor, icon: const Icon(Icons.add, color: Colors.white), diff --git a/lib/screens/albums_screen.dart b/lib/screens/albums_screen.dart index b50094a..d3e489f 100644 --- a/lib/screens/albums_screen.dart +++ b/lib/screens/albums_screen.dart @@ -66,6 +66,7 @@ class _AlbumsScreenState extends ConsumerState { data: (albums) => _buildAlbumsList(albums), ), floatingActionButton: FloatingActionButton.extended( + elevation: 0, onPressed: _showCreateAlbumDialog, backgroundColor: context.accentColor, icon: const Icon(Icons.add, color: Colors.white), diff --git a/lib/screens/folder_detail_screen.dart b/lib/screens/folder_detail_screen.dart index 681b6f8..44c3a95 100644 --- a/lib/screens/folder_detail_screen.dart +++ b/lib/screens/folder_detail_screen.dart @@ -61,6 +61,7 @@ class _FolderDetailScreenState extends ConsumerState { floatingActionButton: _isSelectionMode ? null : FloatingActionButton.extended( + elevation: 0, onPressed: () => _showAddOptions, backgroundColor: context.accentColor, icon: const Icon(Icons.add, color: Colors.white), diff --git a/lib/screens/folders_screen.dart b/lib/screens/folders_screen.dart index 8c54db1..55691fb 100644 --- a/lib/screens/folders_screen.dart +++ b/lib/screens/folders_screen.dart @@ -68,6 +68,7 @@ class _FoldersScreenState extends ConsumerState { }, ), floatingActionButton: FloatingActionButton.extended( + elevation: 0, onPressed: _showCreateOptions, backgroundColor: context.accentColor, icon: const Icon(Icons.create_new_folder, color: Colors.white), diff --git a/lib/screens/note_folders_screen.dart b/lib/screens/note_folders_screen.dart index d28c712..de90c8e 100644 --- a/lib/screens/note_folders_screen.dart +++ b/lib/screens/note_folders_screen.dart @@ -176,6 +176,7 @@ class _NoteFoldersScreenState extends ConsumerState { floatingActionButton: FloatingActionButton( onPressed: _showCreateDialog, backgroundColor: context.accentColor, + elevation: 0, child: const Icon(Icons.add, color: Colors.white), ), ); diff --git a/lib/screens/note_list_screen.dart b/lib/screens/note_list_screen.dart index a8cb6e4..aeb22fb 100644 --- a/lib/screens/note_list_screen.dart +++ b/lib/screens/note_list_screen.dart @@ -240,6 +240,7 @@ class _NoteListScreenState extends ConsumerState { : FloatingActionButton( onPressed: _showNewNoteSheet, backgroundColor: context.accentColor, + elevation: 0, child: const Icon(Icons.add, color: Colors.white), ), ); diff --git a/lib/screens/password_list_screen.dart b/lib/screens/password_list_screen.dart index e09748c..1fc25db 100644 --- a/lib/screens/password_list_screen.dart +++ b/lib/screens/password_list_screen.dart @@ -217,6 +217,7 @@ class _PasswordListScreenState extends ConsumerState { ), ), backgroundColor: context.accentColor, + elevation: 0, child: const Icon(Icons.add, color: Colors.white), ), ); diff --git a/lib/screens/tags_screen.dart b/lib/screens/tags_screen.dart index a6828c2..80ccfe9 100644 --- a/lib/screens/tags_screen.dart +++ b/lib/screens/tags_screen.dart @@ -61,6 +61,7 @@ class _TagsScreenState extends ConsumerState { : _buildTagsListView(tagsAsync), floatingActionButton: _selectedTag == null ? FloatingActionButton.extended( + elevation: 0, onPressed: _showCreateTagDialog, backgroundColor: context.accentColor, icon: const Icon(Icons.add, color: Colors.white), @@ -308,6 +309,7 @@ class _TagsScreenState extends ConsumerState { style: ElevatedButton.styleFrom( backgroundColor: context.accentColor, foregroundColor: Colors.white, + elevation: 0, padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(24), diff --git a/lib/themes/app_theme.dart b/lib/themes/app_theme.dart index 055cfef..8d2c7ce 100644 --- a/lib/themes/app_theme.dart +++ b/lib/themes/app_theme.dart @@ -98,7 +98,7 @@ class AppTheme { floatingActionButtonTheme: FloatingActionButtonThemeData( backgroundColor: primaryColor, foregroundColor: AppColors.darkBackground, - elevation: 4, + elevation: 0, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), ), From 0987dec5d505281a2d41fb96a82c4ab8bfd5921d Mon Sep 17 00:00:00 2001 From: ultraelectronica Date: Wed, 1 Jul 2026 03:27:57 +0800 Subject: [PATCH 19/34] Remove unused re-encryption feature --- lib/providers/vault_providers.dart | 59 +----------------------------- 1 file changed, 1 insertion(+), 58 deletions(-) diff --git a/lib/providers/vault_providers.dart b/lib/providers/vault_providers.dart index ee6c7f1..85424d3 100644 --- a/lib/providers/vault_providers.dart +++ b/lib/providers/vault_providers.dart @@ -1,7 +1,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/legacy.dart'; import '../models/album.dart'; -import '../models/encryption_algorithm.dart'; import '../models/vault_folder.dart'; import '../models/vaulted_file.dart'; import '../services/vault_service.dart'; @@ -494,63 +493,7 @@ final fileCountSummaryProvider = FutureProvider((ref) async { return '$total files'; }); -class ReEncryptProgress { - final bool isInProgress; - final int current; - final int total; - final String? error; - - const ReEncryptProgress({ - this.isInProgress = false, - this.current = 0, - this.total = 0, - this.error, - }); -} - -class ReEncryptNotifier extends Notifier { - @override - ReEncryptProgress build() { - return const ReEncryptProgress(); - } - - VaultService get _vaultService => ref.read(vaultServiceProvider); - - Future reEncryptVault(EncryptionAlgorithm targetAlgorithm, {Set? fileFilter}) async { - state = const ReEncryptProgress(isInProgress: true, current: 0, total: 0); - try { - final result = await _vaultService.reEncryptVault( - targetAlgorithm, - fileFilter: fileFilter, - onProgress: (current, total) { - state = ReEncryptProgress( - isInProgress: true, - current: current, - total: total, - ); - }, - ); - - if (result < 0) { - state = const ReEncryptProgress(error: 'Re-encryption failed'); - } else { - state = ReEncryptProgress(current: result, total: result); - } - - ref.invalidate(vaultSettingsProvider); - ref.invalidate(vaultNotifierProvider); - return result; - } catch (e) { - state = ReEncryptProgress(error: e.toString()); - return -1; - } - } -} - -final reEncryptProvider = - NotifierProvider(() { - return ReEncryptNotifier(); -}); +enum VaultEncryptionAction { encrypt, removeEncryption } // ========== UPDATE PROVIDERS ========== From 07cb126f98b2aac510d5f5bdeedccc56168a2439 Mon Sep 17 00:00:00 2001 From: ultraelectronica Date: Wed, 1 Jul 2026 03:28:01 +0800 Subject: [PATCH 20/34] Extract file info into reusable FileInfoSheet widget --- lib/screens/document_viewer_screen.dart | 86 +++---------------------- 1 file changed, 8 insertions(+), 78 deletions(-) diff --git a/lib/screens/document_viewer_screen.dart b/lib/screens/document_viewer_screen.dart index 24407b5..b3b3a47 100644 --- a/lib/screens/document_viewer_screen.dart +++ b/lib/screens/document_viewer_screen.dart @@ -12,6 +12,7 @@ import '../services/office_converter_service.dart'; import '../themes/app_colors.dart'; import '../utils/toast_utils.dart'; import '../widgets/conversion_warning_dialog.dart'; +import '../widgets/file_info_sheet.dart'; /// Document viewer for PDFs and text files class DocumentViewerScreen extends ConsumerStatefulWidget { @@ -398,84 +399,13 @@ class _DocumentViewerScreenState extends ConsumerState { } void _showFileInfo() { - showModalBottomSheet( - context: context, - backgroundColor: Colors.transparent, - isScrollControlled: true, - builder: (context) => Container( - constraints: BoxConstraints( - maxHeight: MediaQuery.of(context).size.height * 0.7, - ), - decoration: BoxDecoration( - color: context.backgroundColor, - borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), - ), - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.all(20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Document Information', - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - color: context.textPrimary, - fontFamily: 'ProductSans', - ), - ), - const SizedBox(height: 16), - _buildInfoRow('Name', widget.file.originalName), - _buildInfoRow('Type', widget.file.extension.toUpperCase()), - _buildInfoRow('Size', widget.file.formattedSize), - _buildInfoRow('Added', widget.file.formattedDateAdded), - if (widget.file.isEncrypted) - _buildInfoRow('Encrypted', 'Yes'), - if (_isPdf && _totalPages > 0) - _buildInfoRow('Pages', _totalPages.toString()), - SizedBox(height: MediaQuery.of(context).padding.bottom), - ], - ), - ), - ], - ), - ), - ), - ); - } - - Widget _buildInfoRow(String label, String value) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: 100, - child: Text( - label, - style: TextStyle( - fontFamily: 'ProductSans', - color: context.textTertiary, - ), - ), - ), - Expanded( - child: Text( - value, - style: TextStyle( - fontFamily: 'ProductSans', - color: context.textPrimary, - ), - ), - ), - ], - ), + FileInfoSheet.show( + context, + widget.file, + title: 'Document Information', + extraRows: (_isPdf && _totalPages > 0) + ? [('Pages', _totalPages.toString())] + : null, ); } From e95a243403744b1e1a6f7a30edb9ee721bc51a87 Mon Sep 17 00:00:00 2001 From: ultraelectronica Date: Wed, 1 Jul 2026 03:28:05 +0800 Subject: [PATCH 21/34] Add encrypt and remove encryption buttons to settings screen --- lib/screens/encryption_settings_screen.dart | 62 +++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/lib/screens/encryption_settings_screen.dart b/lib/screens/encryption_settings_screen.dart index c265236..32756a4 100644 --- a/lib/screens/encryption_settings_screen.dart +++ b/lib/screens/encryption_settings_screen.dart @@ -4,6 +4,7 @@ import '../models/encryption_algorithm.dart'; import '../providers/vault_providers.dart'; import '../services/vault_service.dart'; import '../themes/app_colors.dart'; +import 'encryption_manage_screen.dart'; import 're_encrypt_file_picker_screen.dart'; class EncryptionSettingsScreen extends ConsumerStatefulWidget { @@ -106,6 +107,67 @@ class _EncryptionSettingsScreenState ), ), const SizedBox(height: 24), + _buildSectionTitle(context, 'Manage File Encryption'), + const SizedBox(height: 8), + Text( + 'Encrypt unencrypted files, or remove encryption to store files as plaintext.', + style: TextStyle( + fontFamily: 'ProductSans', + fontSize: 12, + color: context.textTertiary, + ), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: FilledButton.icon( + onPressed: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => EncryptionManageScreen( + action: VaultEncryptionAction.encrypt, + algorithm: settings.encryptionAlgorithm, + ), + ), + ), + icon: const Icon(Icons.lock_outline), + label: const Text( + 'Encrypt', + style: TextStyle(fontFamily: 'ProductSans'), + ), + style: FilledButton.styleFrom( + backgroundColor: context.accentColor, + foregroundColor: Colors.white, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: FilledButton.icon( + onPressed: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => EncryptionManageScreen( + action: VaultEncryptionAction.removeEncryption, + algorithm: settings.encryptionAlgorithm, + ), + ), + ), + icon: const Icon(Icons.lock_open), + label: const Text( + 'Remove', + style: TextStyle(fontFamily: 'ProductSans'), + ), + style: FilledButton.styleFrom( + backgroundColor: context.surfaceColor, + foregroundColor: context.textPrimary, + ), + ), + ), + ], + ), + const SizedBox(height: 24), _buildSectionTitle(context, 'Current Configuration'), const SizedBox(height: 8), _buildInfoCard(context, settings), From 2d8acdb9b37188663e87902249be42f8dcd46f52 Mon Sep 17 00:00:00 2001 From: ultraelectronica Date: Wed, 1 Jul 2026 03:28:12 +0800 Subject: [PATCH 22/34] Extract file info dialog into reusable FileInfoSheet widget --- lib/screens/gallery_vault_screen.dart | 79 +-------------------------- 1 file changed, 2 insertions(+), 77 deletions(-) diff --git a/lib/screens/gallery_vault_screen.dart b/lib/screens/gallery_vault_screen.dart index 6e3be1d..26ce9cd 100644 --- a/lib/screens/gallery_vault_screen.dart +++ b/lib/screens/gallery_vault_screen.dart @@ -20,6 +20,7 @@ import '../services/whats_new_service.dart'; import '../themes/app_colors.dart'; import '../utils/toast_utils.dart'; import '../utils/responsive_utils.dart'; +import '../widgets/file_info_sheet.dart'; import '../widgets/office_conversion_confirm_dialog.dart'; import '../widgets/per_file_encryption_sheet.dart'; import 'albums_screen.dart'; @@ -1739,83 +1740,7 @@ class _GalleryVaultScreenState extends ConsumerState { /// Show file info dialog void _showFileInfo(VaultedFile file) { - showDialog( - context: context, - builder: (context) => AlertDialog( - backgroundColor: Theme.of(context).scaffoldBackgroundColor, - title: Text( - 'File Info', - style: TextStyle( - fontFamily: 'ProductSans', - color: context.textPrimary, - ), - ), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildInfoRow('Name', file.originalName), - const SizedBox(height: 12), - _buildInfoRow('Type', file.extension.toUpperCase()), - const SizedBox(height: 12), - _buildInfoRow('Size', file.formattedSize), - const SizedBox(height: 12), - _buildInfoRow('Added', _formatDate(file.dateAdded)), - const SizedBox(height: 12), - _buildInfoRow('Encrypted', file.isEncrypted ? 'Yes' : 'No'), - if (file.lastViewed != null) ...[ - const SizedBox(height: 12), - _buildInfoRow('Last Viewed', _formatDate(file.lastViewed!)), - ], - ], - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: Text( - 'Close', - style: TextStyle( - fontFamily: 'ProductSans', - color: context.accentColor, - ), - ), - ), - ], - ), - ); - } - - Widget _buildInfoRow(String label, String value) { - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: 80, - child: Text( - label, - style: TextStyle( - fontFamily: 'ProductSans', - color: context.textSecondary, - fontSize: 14, - ), - ), - ), - Expanded( - child: Text( - value, - style: TextStyle( - fontFamily: 'ProductSans', - color: context.textPrimary, - fontSize: 14, - ), - ), - ), - ], - ); - } - - String _formatDate(DateTime date) { - return '${date.day}/${date.month}/${date.year} ${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}'; + FileInfoSheet.show(context, file, title: 'File Info'); } IconData _getFileIcon(String extension) { From 703b1379406786d563fa1319ca196dc4aa014894 Mon Sep 17 00:00:00 2001 From: ultraelectronica Date: Wed, 1 Jul 2026 03:28:17 +0800 Subject: [PATCH 23/34] Extract file info sheet into reusable widget --- lib/screens/media_viewer_screen.dart | 90 ++++------------------------ 1 file changed, 10 insertions(+), 80 deletions(-) diff --git a/lib/screens/media_viewer_screen.dart b/lib/screens/media_viewer_screen.dart index 72c101c..67d4199 100644 --- a/lib/screens/media_viewer_screen.dart +++ b/lib/screens/media_viewer_screen.dart @@ -13,6 +13,7 @@ import '../providers/vault_providers.dart'; import '../services/auto_kill_service.dart'; import '../themes/app_colors.dart'; import '../utils/toast_utils.dart'; +import '../widgets/file_info_sheet.dart'; enum _VideoLoadPhase { idle, decrypting, initializing, ready } @@ -502,86 +503,15 @@ class _MediaViewerScreenState extends ConsumerState { void _showFileInfo() { final file = _files[_currentIndex]; - - showModalBottomSheet( - context: context, - backgroundColor: Colors.transparent, - isScrollControlled: true, - builder: (context) => Container( - constraints: BoxConstraints( - maxHeight: MediaQuery.of(context).size.height * 0.7, - ), - decoration: BoxDecoration( - color: context.backgroundColor, - borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), - ), - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.all(20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'File Information', - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - color: context.textPrimary, - fontFamily: 'ProductSans', - ), - ), - const SizedBox(height: 16), - _buildInfoRow('Name', file.originalName), - _buildInfoRow('Type', file.type.displayName), - _buildInfoRow('Size', file.formattedSize), - _buildInfoRow('Added', file.formattedDateAdded), - if (file.isEncrypted) _buildInfoRow('Encrypted', 'Yes'), - if (file.hasTags) - _buildInfoRow('Tags', file.tags.join(', ')), - if (file.viewCount > 0) - _buildInfoRow('Views', file.viewCount.toString()), - SizedBox(height: MediaQuery.of(context).padding.bottom), - ], - ), - ), - ], - ), - ), - ), - ); - } - - Widget _buildInfoRow(String label, String value) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: 100, - child: Text( - label, - style: TextStyle( - fontFamily: 'ProductSans', - color: context.textTertiary, - ), - ), - ), - Expanded( - child: Text( - value, - style: TextStyle( - fontFamily: 'ProductSans', - color: context.textPrimary, - ), - ), - ), - ], - ), + final extra = <(String, String)>[]; + if (file.hasTags) extra.add(('Tags', file.tags.join(', '))); + if (file.viewCount > 0) extra.add(('Views', file.viewCount.toString())); + FileInfoSheet.show( + context, + file, + title: 'File Information', + typeLabel: file.type.displayName, + extraRows: extra.isEmpty ? null : extra, ); } From 5c7815c4c0c1fbeb2422d477ca15c2babcd13b79 Mon Sep 17 00:00:00 2001 From: ultraelectronica Date: Wed, 1 Jul 2026 03:28:21 +0800 Subject: [PATCH 24/34] Add search filter and select-all scoping to re-encrypt file picker --- .../re_encrypt_file_picker_screen.dart | 372 +++++++++++------- 1 file changed, 239 insertions(+), 133 deletions(-) diff --git a/lib/screens/re_encrypt_file_picker_screen.dart b/lib/screens/re_encrypt_file_picker_screen.dart index 9e1a5fa..6c8f81b 100644 --- a/lib/screens/re_encrypt_file_picker_screen.dart +++ b/lib/screens/re_encrypt_file_picker_screen.dart @@ -4,6 +4,7 @@ import '../models/encryption_algorithm.dart'; import '../models/vaulted_file.dart'; import '../providers/vault_providers.dart'; import '../themes/app_colors.dart'; +import '../widgets/operation_progress_sheet.dart'; import '../widgets/re_encrypt_warning_dialog.dart'; class ReEncryptFilePickerScreen extends ConsumerStatefulWidget { @@ -21,8 +22,13 @@ class ReEncryptFilePickerScreen extends ConsumerStatefulWidget { class _ReEncryptFilePickerScreenState extends ConsumerState { - Set _selectedIds = {}; + final Set _selectedIds = {}; bool _isReEncrypting = false; + String _searchQuery = ''; + + void _setSearchQuery(String value) { + setState(() => _searchQuery = value.trim().toLowerCase()); + } String _formatSize(int bytes) { if (bytes < 1024) return '$bytes B'; @@ -56,7 +62,8 @@ class _ReEncryptFilePickerScreenState @override Widget build(BuildContext context) { final filesAsync = ref.watch(vaultNotifierProvider); - final reEncryptProgress = ref.watch(reEncryptProvider); + final encryptedFiles = _encryptedFiles(filesAsync); + final filteredFiles = _applySearchFilter(encryptedFiles); return Scaffold( backgroundColor: context.backgroundColor, @@ -80,17 +87,18 @@ class _ReEncryptFilePickerScreenState TextButton( onPressed: () { setState(() { - if (_selectedIds.length == _encryptedFiles(filesAsync).length) { - _selectedIds = {}; + final filteredIds = + filteredFiles.map((f) => f.id).toSet(); + if (filteredIds.every((id) => _selectedIds.contains(id))) { + _selectedIds.removeAll(filteredIds); } else { - _selectedIds = _encryptedFiles(filesAsync) - .map((f) => f.id) - .toSet(); + _selectedIds.addAll(filteredIds); } }); }, child: Text( - _selectedIds.length == _encryptedFiles(filesAsync).length + filteredFiles.isNotEmpty && + filteredFiles.every((f) => _selectedIds.contains(f.id)) ? 'Deselect All' : 'Select All', style: TextStyle( @@ -115,8 +123,6 @@ class _ReEncryptFilePickerScreenState ), ), data: (files) { - final encryptedFiles = _encryptedFiles(filesAsync); - if (encryptedFiles.isEmpty) { return Center( child: Column( @@ -153,7 +159,7 @@ class _ReEncryptFilePickerScreenState Expanded( child: Text( 'Target: ${widget.targetAlgorithm.displayName}\n' - '${_selectedIds.length} of ${encryptedFiles.length} selected', + '${_selectedIds.length} of ${filteredFiles.length} selected', style: TextStyle( fontFamily: 'ProductSans', fontSize: 13, @@ -164,137 +170,151 @@ class _ReEncryptFilePickerScreenState ], ), ), - if (reEncryptProgress.isInProgress && _isReEncrypting) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Column( - children: [ - LinearProgressIndicator( - value: reEncryptProgress.total > 0 - ? reEncryptProgress.current / - reEncryptProgress.total - : null, - backgroundColor: context.dividerColor, - valueColor: AlwaysStoppedAnimation(context.accentColor), - ), - const SizedBox(height: 8), - Text( - 'Re-encrypting ${reEncryptProgress.current} of ${reEncryptProgress.total}...', - style: TextStyle( - fontFamily: 'ProductSans', - fontSize: 12, - color: context.textSecondary, - ), - ), - const SizedBox(height: 16), - ], + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: TextField( + onChanged: _setSearchQuery, + style: TextStyle( + fontFamily: 'ProductSans', + color: context.textPrimary, + fontSize: 14, ), - ), - if (reEncryptProgress.error != null) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Text( - reEncryptProgress.error!, - style: const TextStyle( + decoration: InputDecoration( + hintText: 'Search files...', + hintStyle: TextStyle( fontFamily: 'ProductSans', - color: Colors.red, - fontSize: 12, + color: context.textTertiary, + ), + prefixIcon: Icon(Icons.search, color: context.textTertiary), + suffixIcon: _searchQuery.isNotEmpty + ? IconButton( + icon: Icon(Icons.clear, color: context.textTertiary), + onPressed: () => _setSearchQuery(''), + ) + : null, + filled: true, + fillColor: context.surfaceColor, + contentPadding: const EdgeInsets.symmetric(vertical: 12), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: context.dividerColor), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: context.dividerColor), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: context.accentColor), ), ), ), + ), + const SizedBox(height: 8), Expanded( - child: ListView.builder( - padding: const EdgeInsets.symmetric(horizontal: 16), - itemCount: encryptedFiles.length, - itemBuilder: (context, index) { - final file = encryptedFiles[index]; - final isSelected = _selectedIds.contains(file.id); - final needsReEncrypt = - file.encryptionAlgorithm != widget.targetAlgorithm; - - return Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Container( - decoration: BoxDecoration( - color: isSelected - ? context.accentColor.withValues(alpha: 0.08) - : context.surfaceColor, - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: isSelected - ? context.accentColor - : context.dividerColor, + child: filteredFiles.isEmpty + ? Center( + child: Text( + 'No files match your search', + style: TextStyle( + fontFamily: 'ProductSans', + fontSize: 16, + color: context.textSecondary, ), ), - child: ListTile( - leading: Icon( - _iconForType(file.type), - color: isSelected - ? context.accentColor - : context.textSecondary, - ), - title: Text( - file.originalName, - style: TextStyle( - fontFamily: 'ProductSans', - fontSize: 14, - color: context.textPrimary, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - subtitle: Row( - children: [ - if (file.fileSize > _largeFileThresholdBytes) ...[ - Icon(Icons.warning_amber, size: 14, color: Colors.orange), - const SizedBox(width: 4), - ], - Expanded( - child: Text( - '${_formatSize(file.fileSize)} • ${_formatAlgorithm(file.encryptionAlgorithm)}' - '${needsReEncrypt ? ' → ${_formatAlgorithm(widget.targetAlgorithm)}' : ' (no change)'}', + ) + : ListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 16), + itemCount: filteredFiles.length, + itemBuilder: (context, index) { + final file = filteredFiles[index]; + final isSelected = _selectedIds.contains(file.id); + final needsReEncrypt = + file.encryptionAlgorithm != widget.targetAlgorithm; + + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Container( + decoration: BoxDecoration( + color: isSelected + ? context.accentColor.withValues(alpha: 0.08) + : context.surfaceColor, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isSelected + ? context.accentColor + : context.dividerColor, + ), + ), + child: ListTile( + leading: Icon( + _iconForType(file.type), + color: isSelected + ? context.accentColor + : context.textSecondary, + ), + title: Text( + file.originalName, style: TextStyle( fontFamily: 'ProductSans', - fontSize: 12, - color: needsReEncrypt - ? context.textSecondary - : context.textTertiary, + fontSize: 14, + color: context.textPrimary, ), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), + subtitle: Row( + children: [ + if (file.fileSize > _largeFileThresholdBytes) ...[ + Icon(Icons.warning_amber, size: 14, color: Colors.orange), + const SizedBox(width: 4), + ], + Expanded( + child: Text( + '${_formatSize(file.fileSize)} • ${_formatAlgorithm(file.encryptionAlgorithm)}' + '${needsReEncrypt ? ' → ${_formatAlgorithm(widget.targetAlgorithm)}' : ' (no change)'}', + style: TextStyle( + fontFamily: 'ProductSans', + fontSize: 12, + color: needsReEncrypt + ? context.textSecondary + : context.textTertiary, + ), + ), + ), + ], + ), + trailing: Checkbox( + value: isSelected, + onChanged: _isReEncrypting + ? null + : (value) { + setState(() { + if (value == true) { + _selectedIds.add(file.id); + } else { + _selectedIds.remove(file.id); + } + }); + }, + activeColor: context.accentColor, + ), + onTap: _isReEncrypting + ? null + : () { + setState(() { + if (isSelected) { + _selectedIds.remove(file.id); + } else { + _selectedIds.add(file.id); + } + }); + }, ), - ], - ), - trailing: Checkbox( - value: isSelected, - onChanged: _isReEncrypting - ? null - : (value) { - setState(() { - if (value == true) { - _selectedIds.add(file.id); - } else { - _selectedIds.remove(file.id); - } - }); - }, - activeColor: context.accentColor, - ), - onTap: _isReEncrypting - ? null - : () { - setState(() { - if (isSelected) { - _selectedIds.remove(file.id); - } else { - _selectedIds.add(file.id); - } - }); - }, - ), + ), + ); + }, ), - ); - }, - ), ), Padding( padding: const EdgeInsets.all(16), @@ -333,6 +353,13 @@ class _ReEncryptFilePickerScreenState []; } + List _applySearchFilter(List files) { + if (_searchQuery.isEmpty) return files; + return files + .where((f) => f.originalName.toLowerCase().contains(_searchQuery)) + .toList(); + } + Future _startReEncrypt() async { final encryptedFiles = _encryptedFiles(ref.read(vaultNotifierProvider)); final largeFiles = encryptedFiles @@ -421,21 +448,100 @@ class _ReEncryptFilePickerScreenState if (warningAcknowledged != true || !mounted) return; setState(() => _isReEncrypting = true); - await ref.read(reEncryptProvider.notifier).reEncryptVault( + + final progressState = ValueNotifier( + OperationProgressState( + totalFiles: _selectedIds.length, + currentFileName: 'Preparing...', + statusMessage: 'Starting...', + isProcessing: true, + isEncrypting: true, + ), + ); + + if (!mounted) { + progressState.dispose(); + return; + } + + void onProgress( + int current, int total, String name, int processed, int totalBytes, + ) { + progressState.value = progressState.value.copyWith( + totalFiles: total, + currentFile: current, + currentFileName: name, + totalSizeBytes: totalBytes, + processedSizeBytes: processed, + statusMessage: current >= total + ? 'Finalizing...' + : 'Processing ${current + 1} of $total...', + ); + } + + final sheetFuture = showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + isDismissible: false, + enableDrag: false, + builder: (ctx) => ValueListenableBuilder( + valueListenable: progressState, + builder: (context, state, _) => OperationProgressSheet( + operationType: OperationType.reencrypt, + totalFiles: state.totalFiles, + currentFile: state.currentFile, + currentFileName: state.currentFileName, + totalSizeBytes: state.totalSizeBytes, + processedSizeBytes: state.processedSizeBytes, + statusMessage: state.statusMessage, + isProcessing: state.isProcessing, + isComplete: state.isComplete, + isEncrypting: state.isEncrypting, + ), + ), + ); + + final result = await ref.read(vaultServiceProvider).reEncryptVault( widget.targetAlgorithm, fileFilter: _selectedIds, + onProgress: onProgress, ); - if (mounted) { + + if (!mounted) { + progressState.dispose(); + return; + } + + ref.invalidate(vaultSettingsProvider); + ref.invalidate(vaultNotifierProvider); + + if (result < 0) { + Navigator.of(context).pop(); // close sheet + progressState.dispose(); setState(() => _isReEncrypting = false); ScaffoldMessenger.of(context).showSnackBar( - SnackBar( + const SnackBar( content: Text( - 'Re-encryption complete', + 'Re-encryption failed', style: TextStyle(fontFamily: 'ProductSans'), ), ), ); - ref.invalidate(vaultNotifierProvider); + return; } + + progressState.value = progressState.value.copyWith( + isComplete: true, + isProcessing: false, + currentFile: progressState.value.totalFiles, + processedSizeBytes: progressState.value.totalSizeBytes, + statusMessage: 'Completed successfully', + ); + + await sheetFuture; // user taps Done + progressState.dispose(); + if (!mounted) return; + setState(() => _isReEncrypting = false); + Navigator.of(context).pop(); // pop picker } } From 2e627e94e6cc537f91519ee4c15d6ee3e04c4d73 Mon Sep 17 00:00:00 2001 From: ultraelectronica Date: Wed, 1 Jul 2026 03:28:26 +0800 Subject: [PATCH 25/34] Add encryptFileInPlace and decryptFileInPlace with progress callback Refactor reEncryptFile to use _isLegacyCbcFile instead of detectFileFormat for reliable CBC detection until header_codec endianness bug is fixed --- lib/services/encryption_service.dart | 105 ++++++++++++++++++++++++++- 1 file changed, 103 insertions(+), 2 deletions(-) diff --git a/lib/services/encryption_service.dart b/lib/services/encryption_service.dart index 157618f..a102a14 100644 --- a/lib/services/encryption_service.dart +++ b/lib/services/encryption_service.dart @@ -1503,9 +1503,9 @@ class EncryptionService { bool isDecoy = false, Uint8List? oldDerivedKey, Uint8List? newDerivedKey, + void Function(int bytesProcessed, int totalBytes, bool isEncrypt)? onProgress, }) async { - final format = detectFileFormat(filePath); - final isLegacyCbc = (format == 0 || format == 3); + final isLegacyCbc = _isLegacyCbcFile(filePath); final key = await _resolveKey(isDecoy: isDecoy, derivedKey: oldDerivedKey); final tempDir = await _createAppPrivateTemp('lkr_reencrypt_'); @@ -1526,6 +1526,9 @@ class EncryptionService { destinationPath: tempDecPath, key: key, ivBase64: oldIvBase64, + onProgress: onProgress == null + ? null + : (p, t) => onProgress(p, t, false), ); final decResult = await decJob.future; if (decResult.needsMigration) { @@ -1540,6 +1543,9 @@ class EncryptionService { destinationPath: filePath, key: newKey, useGcm: useGcm, + onProgress: onProgress == null + ? null + : (p, t) => onProgress(p, t, true), ); final encResult = await encJob.future; @@ -1555,10 +1561,105 @@ class EncryptionService { } } + /// Encrypt a plaintext file in place, overwriting [filePath] with ciphertext. + /// Atomic: the pool writes a temp file, then we rename it onto [filePath]. + /// Adds encryption to an already-vaulted plaintext file. Returns the new IV (base64). + Future encryptFileInPlace( + String filePath, { + required bool useGcm, + bool isDecoy = false, + required Uint8List derivedKey, + void Function(int bytesProcessed, int totalBytes)? onProgress, + }) async { + final tempDir = await _createAppPrivateTemp('lkr_encrypt_'); + final tempEncPath = '${tempDir.path}/encrypted'; + try { + try { await File('$filePath.tmp').delete(); } catch (_) {} + + final key = await _resolveKey(isDecoy: isDecoy, derivedKey: derivedKey); + final encJob = _pool!.encryptFile( + sourcePath: filePath, + destinationPath: tempEncPath, + key: key, + useGcm: useGcm, + onProgress: onProgress, + ); + final encResult = await encJob.future; + + // pool wrote tempEncPath.tmp -> tempEncPath; atomically replace the original. + await File(tempEncPath).rename(filePath); + return encResult.ivBase64!; + } finally { + try { await tempDir.delete(recursive: true); } catch (_) {} + } + } + + /// Decrypt a file in place, overwriting [filePath] with plaintext and thus + /// removing its encryption. Atomic via temp + rename. Legacy CBC is decrypted + /// to memory (like reEncryptFile); GCM/CTR go through the isolate pool. + Future decryptFileInPlace( + String filePath, + String ivBase64, { + bool isDecoy = false, + Uint8List? derivedKey, + void Function(int bytesProcessed, int totalBytes)? onProgress, + }) async { + final isLegacyCbc = _isLegacyCbcFile(filePath); + final tempDir = await _createAppPrivateTemp('lkr_decrypt_'); + final tempDecPath = '${tempDir.path}/decrypted'; + try { + try { await File('$filePath.tmp').delete(); } catch (_) {} + + if (isLegacyCbc) { + final decrypted = await decryptFileToMemory( + filePath, ivBase64, + isDecoy: isDecoy, derivedKey: derivedKey, + ); + if (!decrypted.success || decrypted.data == null) { + throw Exception('Failed to decrypt CBC file: ${decrypted.error}'); + } + await File(tempDecPath).writeAsBytes(decrypted.data!); + } else { + final key = await _resolveKey(isDecoy: isDecoy, derivedKey: derivedKey); + final decJob = _pool!.decryptFile( + encryptedPath: filePath, + destinationPath: tempDecPath, + key: key, + ivBase64: ivBase64, + onProgress: onProgress, + ); + await decJob.future; + } + + await File(tempDecPath).rename(filePath); + } finally { + try { await tempDir.delete(recursive: true); } catch (_) {} + } + } + /// Check what encryption format a file uses /// Returns: 0=unknown/legacy CBC, 1=GCM, 2=CTR, 3=CBC with header int detectFileFormat(String filePath) => HeaderCodec.detectFormatFromFile(filePath); + // ponytail: direct magic-byte check instead of detectFileFormat, whose LE/BE + // mismatch in header_codec.dart always returns 0 (format unknown). GCM/CTR + // must reach the streaming isolate pool; only real CBC (or unreadable) keeps + // the proven whole-file decryptFileToMemory path. Upgrade path: fix the + // header_codec endianness bug and this collapses back to detectFileFormat. + bool _isLegacyCbcFile(String path) { + try { + final f = File(path).openSync(mode: FileMode.read); + final b = f.readSync(4); + f.closeSync(); + if (b.length < 4) return true; + // On-disk prefix is always 0x4C,0x4B,0x52,X. X: 0x47=GCMv1,0x32=GCMv2,0x53=CTR,0x44=CBC. + final tag = b[3]; + return tag != 0x47 && tag != 0x32 && tag != 0x53; + } catch (_) { + return true; + } + } + static const String _rotationJournalKey = 'key_rotation_journal'; static const String _oldMasterKeyKey = 'vault_master_key_old'; From a995ed8241dcc7f480be1d7bfc51ac51e95a907b Mon Sep 17 00:00:00 2001 From: ultraelectronica Date: Wed, 1 Jul 2026 03:29:20 +0800 Subject: [PATCH 26/34] Add encryption and decryption operations and improve re-encrypt progress Extend `VaultService` with `encryptVaultFiles` and `removeEncryption` methods for in-place encryption/decryption of vault files. Refactor `reEncryptVault` to report per-file progress, handle missing IVs gracefully, filter files from journal recovery, and use async key derivation to avoid UI hangs. --- lib/services/vault_service.dart | 241 +++++++++++++++++++++++++++++++- 1 file changed, 235 insertions(+), 6 deletions(-) diff --git a/lib/services/vault_service.dart b/lib/services/vault_service.dart index 0d7c7b5..e2e1b0d 100644 --- a/lib/services/vault_service.dart +++ b/lib/services/vault_service.dart @@ -2223,7 +2223,9 @@ class VaultService { Future reEncryptVault( EncryptionAlgorithm targetAlgorithm, { bool isDecoy = false, - Function(int current, int total)? onProgress, + Function(int current, int total, String currentFileName, int processedBytes, + int totalBytes)? + onProgress, Set? fileFilter, }) async { try { @@ -2261,25 +2263,59 @@ class VaultService { } encryptedFiles = files.where((f) => f.isEncrypted).toList(); + // ponytail: journal recovery rebuilds from the full list (to restore + // IVs from vaultPath matches), so re-apply the selection here — + // without this a leftover journal makes reEncryptVault ignore + // fileFilter and process every encrypted file. + if (fileFilter != null && fileFilter.isNotEmpty) { + encryptedFiles = + encryptedFiles.where((f) => fileFilter.contains(f.id)).toList(); + } if (isDecoy) { _cachedDecoyFiles = files; } else { _cachedFiles = files; } await _saveFileIndex(isDecoy: isDecoy); } catch (_) {} } + final totalBytes = encryptedFiles.fold(0, (s, f) => s + f.fileSize); + var processedBytes = 0; int reEncryptedCount = 0; for (int i = 0; i < encryptedFiles.length; i++) { - onProgress?.call(i + 1, encryptedFiles.length); - final file = encryptedFiles[i]; - if (!await File(file.vaultPath).exists()) continue; + onProgress?.call(i, encryptedFiles.length, file.originalName, + processedBytes, totalBytes); + + if (!await File(file.vaultPath).exists()) { + processedBytes += file.fileSize; + onProgress?.call(i + 1, encryptedFiles.length, file.originalName, + processedBytes, totalBytes); + continue; + } + // ponytail: an encrypted file with no stored IV is undecryptable on any + // path (the IV is never embedded in the file — pool encrypt returns it + // for the index to store). Skip it instead of throwing "IV must be at + // least 1 byte" and aborting the whole batch; matches removeEncryption's + // guard. These are legacy/orphaned entries, not normal imports. + if (file.encryptionIv == null || file.encryptionIv!.isEmpty) { + debugPrint( + 'reEncryptVault: skipping ${file.originalName} (missing IV)'); + processedBytes += file.fileSize; + onProgress?.call(i + 1, encryptedFiles.length, file.originalName, + processedBytes, totalBytes); + continue; + } final currentFormat = _encryptionService.detectFileFormat(file.vaultPath); final targetIsGcm = targetAlgorithm == EncryptionAlgorithm.aes256Gcm; final currentIsGcm = (currentFormat == 1 || currentFormat == 4); - if (currentIsGcm == targetIsGcm && currentFormat != 0 && currentFormat != 3) continue; + if (currentIsGcm == targetIsGcm && currentFormat != 0 && currentFormat != 3) { + processedBytes += file.fileSize; + onProgress?.call(i + 1, encryptedFiles.length, file.originalName, + processedBytes, totalBytes); + continue; + } await _storage.write( key: _reEncryptJournalKey, @@ -2293,8 +2329,13 @@ class VaultService { final newSalt = _encryptionService.generateFileSalt(); final newIterations = _cachedSettings?.kdfIterations ?? 100000; final masterKey = await _encryptionService.getMasterKey(isDecoy: isDecoy || file.isDecoy); - final newDerivedKey = _encryptionService.deriveFileKey(masterKey, newSalt, newIterations); + // ponytail: async (Compute isolate). Sync deriveFileKey @ 100k iterations + // per file on the main isolate was the re-encrypt hang; encrypt (line ~702) + // and the old-key derivation above both already use the async path. + final newDerivedKey = await _encryptionService.deriveFileKeyAsync(masterKey, newSalt, newIterations); + final baseBytes = processedBytes; + final halfBytes = file.fileSize ~/ 2; final newIv = await _encryptionService.reEncryptFile( file.vaultPath, file.encryptionIv ?? '', @@ -2302,6 +2343,19 @@ class VaultService { isDecoy: isDecoy, oldDerivedKey: oldDerivedKey, newDerivedKey: newDerivedKey, + onProgress: onProgress == null + ? null + // ponytail: fold decrypt(0..half) then encrypt(half..full) into one + // monotonic budget so the bar advances across both pool stages. + : (processed, _, isEnc) => onProgress( + i, + encryptedFiles.length, + file.originalName, + baseBytes + + (isEnc + ? halfBytes + (processed ~/ 2) + : processed ~/ 2), + totalBytes), ); journalIvs[file.vaultPath] = newIv; @@ -2322,6 +2376,9 @@ class VaultService { ); reEncryptedCount++; } + processedBytes += file.fileSize; + onProgress?.call(i + 1, encryptedFiles.length, file.originalName, + processedBytes, totalBytes); } if (isDecoy) { @@ -2339,6 +2396,178 @@ class VaultService { } } + /// Add encryption to currently-unencrypted vault files, in place. + /// Same reliability profile as reEncryptVault: isolate pool + async key + /// derivation + atomic temp/rename. [algorithm] picks CTR vs GCM. + /// ponytail: index saved ONCE at the end (not per file). A per-file save + /// jsonEncodes + re-writes the whole index N times through secure storage, + /// which froze the UI on batches — reEncryptVault saves once for the same + /// reason. Each file is still atomic (temp + rename), so a crash can only + /// ever leave a completed file's bytes consistent; the index is reconciled + /// by re-running the op against the still-eligible files. + Future encryptVaultFiles( + EncryptionAlgorithm algorithm, { + bool isDecoy = false, + Function(int current, int total, String currentFileName, int processedBytes, + int totalBytes)? + onProgress, + Set? fileFilter, + }) async { + try { + final files = await getAllFiles(isDecoy: isDecoy); + var targets = files.where((f) => !f.isEncrypted).toList(); + if (fileFilter != null && fileFilter.isNotEmpty) { + targets = targets.where((f) => fileFilter.contains(f.id)).toList(); + } + if (targets.isEmpty) return 0; + + final iterations = _cachedSettings?.kdfIterations ?? 100000; + final totalBytes = targets.fold(0, (s, f) => s + f.fileSize); + var processedBytes = 0; + var count = 0; + + for (int i = 0; i < targets.length; i++) { + final file = targets[i]; + onProgress?.call(i, targets.length, file.originalName, processedBytes, + totalBytes); + if (!await File(file.vaultPath).exists()) { + processedBytes += file.fileSize; + continue; + } + + final salt = _encryptionService.generateFileSalt(); + final masterKey = await _encryptionService.getMasterKey(isDecoy: isDecoy || file.isDecoy); + final derivedKey = await _encryptionService.deriveFileKeyAsync(masterKey, salt, iterations); + + final baseBytes = processedBytes; + final newIv = await _encryptionService.encryptFileInPlace( + file.vaultPath, + useGcm: algorithm == EncryptionAlgorithm.aes256Gcm, + isDecoy: isDecoy, + derivedKey: derivedKey, + onProgress: onProgress == null + ? null + : (processed, _) => onProgress( + i, targets.length, file.originalName, + baseBytes + processed, totalBytes), + ); + + final idx = files.indexWhere((f) => f.id == file.id); + if (idx >= 0) { + files[idx] = file.copyWith( + isEncrypted: true, + encryptionIv: newIv, + encryptionAlgorithm: algorithm, + keyDerivationSalt: base64Encode(salt), + kdfIterations: iterations, + ); + count++; + } + processedBytes += file.fileSize; + onProgress?.call(i + 1, targets.length, file.originalName, + processedBytes, totalBytes); + } + + await _saveFileIndex(isDecoy: isDecoy); + return count; + } catch (e) { + // Persist whatever completed before the failure so the index matches disk. + try { await _saveFileIndex(isDecoy: isDecoy); } catch (_) {} + debugPrint('Encrypt-in-place error: $e'); + return -1; + } + } + + /// Remove encryption from encrypted vault files, decrypting them in place + /// so they are stored as plaintext. Same reliability profile as reEncryptVault. + Future removeEncryption({ + bool isDecoy = false, + Function(int current, int total, String currentFileName, int processedBytes, + int totalBytes)? + onProgress, + Set? fileFilter, + }) async { + try { + final files = await getAllFiles(isDecoy: isDecoy); + var targets = files.where((f) => f.isEncrypted).toList(); + if (fileFilter != null && fileFilter.isNotEmpty) { + targets = targets.where((f) => fileFilter.contains(f.id)).toList(); + } + if (targets.isEmpty) return 0; + + final totalBytes = targets.fold(0, (s, f) => s + f.fileSize); + var processedBytes = 0; + var count = 0; + + for (int i = 0; i < targets.length; i++) { + final file = targets[i]; + onProgress?.call(i, targets.length, file.originalName, processedBytes, + totalBytes); + if (!await File(file.vaultPath).exists()) { + processedBytes += file.fileSize; + continue; + } + if (file.encryptionIv == null) { + processedBytes += file.fileSize; + continue; + } + + final derivedKey = await _deriveKeyForFile(file, isDecoy: isDecoy); + + final baseBytes = processedBytes; + await _encryptionService.decryptFileInPlace( + file.vaultPath, + file.encryptionIv!, + isDecoy: isDecoy, + derivedKey: derivedKey, + onProgress: onProgress == null + ? null + : (processed, _) => onProgress( + i, targets.length, file.originalName, + baseBytes + processed, totalBytes), + ); + + final idx = files.indexWhere((f) => f.id == file.id); + if (idx >= 0) { + // copyWith can't null out fields, so rebuild clearing the crypto ones. + files[idx] = VaultedFile( + id: file.id, + originalName: file.originalName, + vaultPath: file.vaultPath, + originalPath: file.originalPath, + type: file.type, + mimeType: file.mimeType, + fileSize: file.fileSize, + dateAdded: file.dateAdded, + dateModified: DateTime.now(), + thumbnailPath: file.thumbnailPath, + metadata: file.metadata, + tags: file.tags, + isFavorite: file.isFavorite, + isEncrypted: false, + isDecoy: file.isDecoy, + lastViewed: file.lastViewed, + viewCount: file.viewCount, + notes: file.notes, + albumIds: file.albumIds, + folderId: file.folderId, + ); + count++; + } + processedBytes += file.fileSize; + onProgress?.call(i + 1, targets.length, file.originalName, + processedBytes, totalBytes); + } + + await _saveFileIndex(isDecoy: isDecoy); + return count; + } catch (e) { + try { await _saveFileIndex(isDecoy: isDecoy); } catch (_) {} + debugPrint('Remove-encryption error: $e'); + return -1; + } + } + /// Clear all vault data (use with caution!) Future clearVault({bool isDecoy = false}) async { try { From 6519d119f5486602a57981d27c7cd64c2883d249 Mon Sep 17 00:00:00 2001 From: ultraelectronica Date: Wed, 1 Jul 2026 03:29:27 +0800 Subject: [PATCH 27/34] Extract file info dialog into FileInfoSheet widget --- lib/widgets/explorer_file_grid.dart | 79 +---------------------------- 1 file changed, 2 insertions(+), 77 deletions(-) diff --git a/lib/widgets/explorer_file_grid.dart b/lib/widgets/explorer_file_grid.dart index 2df11f1..94824d0 100644 --- a/lib/widgets/explorer_file_grid.dart +++ b/lib/widgets/explorer_file_grid.dart @@ -13,6 +13,7 @@ import '../services/auto_kill_service.dart'; import '../themes/app_colors.dart'; import '../utils/responsive_utils.dart'; import '../utils/toast_utils.dart'; +import '../widgets/file_info_sheet.dart'; import '../widgets/media_hold_action_sheet.dart'; import 'optimized_image_widget.dart'; @@ -1088,83 +1089,7 @@ class ExplorerFileGrid extends ConsumerWidget { } void _showFileInfo(BuildContext ctx, VaultedFile file) { - String formatDate(DateTime date) { - return '${date.day}/${date.month}/${date.year} ${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}'; - } - - showDialog( - context: ctx, - builder: (ctx) => AlertDialog( - backgroundColor: Theme.of(ctx).scaffoldBackgroundColor, - title: Text( - 'File Info', - style: TextStyle( - fontFamily: 'ProductSans', - color: ctx.textPrimary, - ), - ), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildInfoRow(ctx, 'Name', file.originalName), - const SizedBox(height: 12), - _buildInfoRow(ctx, 'Type', file.extension.toUpperCase()), - const SizedBox(height: 12), - _buildInfoRow(ctx, 'Size', file.formattedSize), - const SizedBox(height: 12), - _buildInfoRow(ctx, 'Added', formatDate(file.dateAdded)), - const SizedBox(height: 12), - _buildInfoRow(ctx, 'Encrypted', file.isEncrypted ? 'Yes' : 'No'), - if (file.lastViewed != null) ...[ - const SizedBox(height: 12), - _buildInfoRow(ctx, 'Last Viewed', formatDate(file.lastViewed!)), - ], - ], - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx), - child: Text( - 'Close', - style: TextStyle( - fontFamily: 'ProductSans', - color: ctx.accentColor, - ), - ), - ), - ], - ), - ); - } - - Widget _buildInfoRow(BuildContext context, String label, String value) { - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: 80, - child: Text( - label, - style: TextStyle( - fontFamily: 'ProductSans', - color: context.textSecondary, - fontSize: 14, - ), - ), - ), - Expanded( - child: Text( - value, - style: TextStyle( - fontFamily: 'ProductSans', - color: context.textPrimary, - fontSize: 14, - ), - ), - ), - ], - ); + FileInfoSheet.show(ctx, file, title: 'File Info'); } IconData _getFileIcon(String extension) { From 4ab6f0238eaa1d9fd8b3e9ea1f99b0e653d40f39 Mon Sep 17 00:00:00 2001 From: ultraelectronica Date: Wed, 1 Jul 2026 03:29:31 +0800 Subject: [PATCH 28/34] Add decrypt and reencrypt operations to progress sheet --- lib/widgets/operation_progress_sheet.dart | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/lib/widgets/operation_progress_sheet.dart b/lib/widgets/operation_progress_sheet.dart index 49864eb..9efa99d 100644 --- a/lib/widgets/operation_progress_sheet.dart +++ b/lib/widgets/operation_progress_sheet.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import '../themes/app_colors.dart'; -enum OperationType { hide, unhide, delete, encrypt } +enum OperationType { hide, unhide, delete, encrypt, decrypt, reencrypt } class OperationProgressSheet extends StatefulWidget { final OperationType operationType; @@ -163,6 +163,14 @@ class _OperationProgressSheetState extends State { icon = Icons.enhanced_encryption_outlined; color = Colors.orange; break; + case OperationType.decrypt: + icon = Icons.lock_open_outlined; + color = Colors.green; + break; + case OperationType.reencrypt: + icon = Icons.autorenew; + color = Colors.blue; + break; } return Container( @@ -196,6 +204,10 @@ class _OperationProgressSheetState extends State { return 'Deleting Files'; case OperationType.encrypt: return 'Encrypting Files'; + case OperationType.decrypt: + return 'Removing Encryption'; + case OperationType.reencrypt: + return 'Re-encrypting Files'; } } From 08bae5d620c31201a912942b8bba4a90810e7643 Mon Sep 17 00:00:00 2001 From: ultraelectronica Date: Wed, 1 Jul 2026 03:29:37 +0800 Subject: [PATCH 29/34] Add encryption management screen for batch encrypt/decrypt --- lib/screens/encryption_manage_screen.dart | 611 ++++++++++++++++++++++ 1 file changed, 611 insertions(+) create mode 100644 lib/screens/encryption_manage_screen.dart diff --git a/lib/screens/encryption_manage_screen.dart b/lib/screens/encryption_manage_screen.dart new file mode 100644 index 0000000..9c172c2 --- /dev/null +++ b/lib/screens/encryption_manage_screen.dart @@ -0,0 +1,611 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../models/encryption_algorithm.dart'; +import '../models/vaulted_file.dart'; +import '../providers/vault_providers.dart'; +import '../themes/app_colors.dart'; +import '../widgets/operation_progress_sheet.dart'; + +class EncryptionManageScreen extends ConsumerStatefulWidget { + final VaultEncryptionAction action; + + /// Algorithm to apply when adding encryption. Ignored for removeEncryption. + final EncryptionAlgorithm algorithm; + + const EncryptionManageScreen({ + super.key, + required this.action, + required this.algorithm, + }); + + @override + ConsumerState createState() => + _EncryptionManageScreenState(); +} + +class _EncryptionManageScreenState + extends ConsumerState { + final Set _selectedIds = {}; + bool _isRunning = false; + String _searchQuery = ''; + + static const int _largeFileThresholdBytes = 50 * 1024 * 1024; + + bool get _isEncrypt => widget.action == VaultEncryptionAction.encrypt; + + String get _actionVerb => _isEncrypt ? 'Encrypt' : 'Remove Encryption'; + + void _setSearchQuery(String value) => + setState(() => _searchQuery = value.trim().toLowerCase()); + + String _formatSize(int bytes) { + if (bytes < 1024) return '$bytes B'; + if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; + if (bytes < 1024 * 1024 * 1024) { + return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; + } + return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB'; + } + + String _formatAlgorithm(EncryptionAlgorithm? algo) { + if (algo == null) return 'None'; + return algo == EncryptionAlgorithm.aes256Ctr ? 'AES-CTR' : 'AES-GCM'; + } + + IconData _iconForType(VaultedFileType type) { + switch (type) { + case VaultedFileType.image: + return Icons.image_outlined; + case VaultedFileType.video: + return Icons.videocam_outlined; + case VaultedFileType.song: + return Icons.audiotrack_outlined; + case VaultedFileType.document: + return Icons.description_outlined; + default: + return Icons.insert_drive_file_outlined; + } + } + + List _eligibleFiles(AsyncValue> filesAsync) { + return filesAsync.whenOrNull( + data: (files) => files + .where((f) => _isEncrypt ? !f.isEncrypted : f.isEncrypted) + .toList(), + ) ?? + []; + } + + List _applySearchFilter(List files) { + if (_searchQuery.isEmpty) return files; + return files + .where((f) => f.originalName.toLowerCase().contains(_searchQuery)) + .toList(); + } + + @override + Widget build(BuildContext context) { + final filesAsync = ref.watch(vaultNotifierProvider); + final eligible = _eligibleFiles(filesAsync); + final filtered = _applySearchFilter(eligible); + + return Scaffold( + backgroundColor: context.backgroundColor, + appBar: AppBar( + leading: IconButton( + icon: Icon(Icons.arrow_back, color: context.textPrimary), + onPressed: _isRunning ? null : () => Navigator.pop(context), + ), + title: Text( + 'Select Files to $_actionVerb', + style: TextStyle( + fontFamily: 'ProductSans', + color: context.textPrimary, + fontWeight: FontWeight.w600, + ), + ), + backgroundColor: Colors.transparent, + elevation: 0, + actions: [ + if (!_isRunning) + TextButton( + onPressed: () { + setState(() { + final filteredIds = filtered.map((f) => f.id).toSet(); + if (filteredIds.every((id) => _selectedIds.contains(id))) { + _selectedIds.removeAll(filteredIds); + } else { + _selectedIds.addAll(filteredIds); + } + }); + }, + child: Text( + filtered.isNotEmpty && + filtered.every((f) => _selectedIds.contains(f.id)) + ? 'Deselect All' + : 'Select All', + style: TextStyle( + fontFamily: 'ProductSans', + color: context.accentColor, + ), + ), + ), + ], + ), + body: filesAsync.when( + loading: () => Center( + child: CircularProgressIndicator(color: context.accentColor), + ), + error: (_, __) => Center( + child: Text( + 'Failed to load files', + style: TextStyle( + fontFamily: 'ProductSans', + color: context.textPrimary, + ), + ), + ), + data: (_) { + if (eligible.isEmpty) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + _isEncrypt ? Icons.lock_open : Icons.lock_outline, + size: 64, + color: context.textTertiary, + ), + const SizedBox(height: 16), + Text( + _isEncrypt + ? 'No unencrypted files' + : 'No encrypted files', + style: TextStyle( + fontFamily: 'ProductSans', + fontSize: 18, + color: context.textSecondary, + ), + ), + ], + ), + ); + } + + return Column( + children: [ + Container( + padding: const EdgeInsets.all(16), + margin: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: context.accentColor.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon(Icons.info_outline, + color: context.accentColor, size: 20), + const SizedBox(width: 12), + Expanded( + child: Text( + _isEncrypt + ? 'Will encrypt with ${widget.algorithm.displayName}\n' + '${_selectedIds.length} of ${filtered.length} selected' + : 'Encryption will be removed (files stored as plaintext)\n' + '${_selectedIds.length} of ${filtered.length} selected', + style: TextStyle( + fontFamily: 'ProductSans', + fontSize: 13, + color: context.textPrimary, + ), + ), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: TextField( + onChanged: _setSearchQuery, + style: TextStyle( + fontFamily: 'ProductSans', + color: context.textPrimary, + fontSize: 14, + ), + decoration: InputDecoration( + hintText: 'Search files...', + hintStyle: TextStyle( + fontFamily: 'ProductSans', + color: context.textTertiary, + ), + prefixIcon: + Icon(Icons.search, color: context.textTertiary), + suffixIcon: _searchQuery.isNotEmpty + ? IconButton( + icon: + Icon(Icons.clear, color: context.textTertiary), + onPressed: () => _setSearchQuery(''), + ) + : null, + filled: true, + fillColor: context.surfaceColor, + contentPadding: + const EdgeInsets.symmetric(vertical: 12), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: context.dividerColor), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: context.dividerColor), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: context.accentColor), + ), + ), + ), + ), + const SizedBox(height: 8), + Expanded( + child: filtered.isEmpty + ? Center( + child: Text( + 'No files match your search', + style: TextStyle( + fontFamily: 'ProductSans', + fontSize: 16, + color: context.textSecondary, + ), + ), + ) + : ListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 16), + itemCount: filtered.length, + itemBuilder: (context, index) { + final file = filtered[index]; + final isSelected = _selectedIds.contains(file.id); + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Container( + decoration: BoxDecoration( + color: isSelected + ? context.accentColor + .withValues(alpha: 0.08) + : context.surfaceColor, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isSelected + ? context.accentColor + : context.dividerColor, + ), + ), + child: ListTile( + leading: Icon( + _iconForType(file.type), + color: isSelected + ? context.accentColor + : context.textSecondary, + ), + title: Text( + file.originalName, + style: TextStyle( + fontFamily: 'ProductSans', + fontSize: 14, + color: context.textPrimary, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: Row( + children: [ + if (file.fileSize > + _largeFileThresholdBytes) ...[ + Icon(Icons.warning_amber, + size: 14, color: Colors.orange), + const SizedBox(width: 4), + ], + Expanded( + child: Text( + '${_formatSize(file.fileSize)} • ${_formatAlgorithm(file.encryptionAlgorithm)}', + style: TextStyle( + fontFamily: 'ProductSans', + fontSize: 12, + color: context.textSecondary, + ), + ), + ), + ], + ), + trailing: Checkbox( + value: isSelected, + onChanged: _isRunning + ? null + : (value) { + setState(() { + if (value == true) { + _selectedIds.add(file.id); + } else { + _selectedIds.remove(file.id); + } + }); + }, + activeColor: context.accentColor, + ), + onTap: _isRunning + ? null + : () { + setState(() { + if (isSelected) { + _selectedIds.remove(file.id); + } else { + _selectedIds.add(file.id); + } + }); + }, + ), + ), + ); + }, + ), + ), + Padding( + padding: const EdgeInsets.all(16), + child: SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: _selectedIds.isEmpty || _isRunning + ? null + : _start, + icon: Icon(_isEncrypt ? Icons.lock_outline : Icons.lock_open), + label: Text( + '$_actionVerb ${_selectedIds.length} File(s)', + style: const TextStyle(fontFamily: 'ProductSans'), + ), + style: FilledButton.styleFrom( + backgroundColor: context.accentColor, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 16), + ), + ), + ), + ), + ], + ); + }, + ), + ); + } + + Future _start() async { + final eligible = _eligibleFiles(ref.read(vaultNotifierProvider)); + final largeFiles = eligible + .where((f) => + _selectedIds.contains(f.id) && + f.fileSize > _largeFileThresholdBytes) + .toList(); + + if (largeFiles.isNotEmpty) { + final proceed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: context.surfaceColor, + title: Row( + children: [ + Icon(Icons.warning_amber_rounded, + color: Colors.orange, size: 24), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Large Files Detected', + style: TextStyle( + fontFamily: 'ProductSans', + color: context.textPrimary, + ), + ), + ), + ], + ), + content: Text( + '${largeFiles.length} file(s) over 50MB will be processed. This may ' + 'take a long time and could corrupt files if interrupted.', + style: TextStyle( + fontFamily: 'ProductSans', + color: context.textSecondary, + fontSize: 14, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: Text('Cancel', + style: TextStyle(color: context.textSecondary)), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + style: FilledButton.styleFrom(backgroundColor: Colors.orange), + child: const Text('Proceed'), + ), + ], + ), + ); + if (proceed != true || !mounted) return; + } + + final confirmed = await showDialog( + context: context, + barrierDismissible: false, + builder: (ctx) => AlertDialog( + backgroundColor: context.surfaceColor, + shape: + RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + title: Row( + children: [ + Icon(Icons.warning_amber_rounded, + color: AppColors.warning, size: 28), + const SizedBox(width: 12), + Expanded( + child: Text( + '$_actionVerb Warning', + style: TextStyle( + fontFamily: 'ProductSans', + fontWeight: FontWeight.bold, + color: context.textPrimary, + fontSize: 20, + ), + ), + ), + ], + ), + content: Text( + _isEncrypt + ? 'Each selected file will be rewritten with encryption. If the ' + 'process is interrupted — dead battery, crash, force-stop, or ' + 'running out of storage — some files may be left corrupted and ' + 'permanently unrecoverable. Ensure your device is charged and ' + 'has enough free storage before continuing.' + : 'Each selected file will be decrypted and rewritten as plaintext, ' + 'removing its encryption. If the process is interrupted, some ' + 'files may be left corrupted and permanently unrecoverable. ' + 'Ensure your device is charged and has enough free storage ' + 'before continuing.', + style: TextStyle( + fontFamily: 'ProductSans', + color: context.textSecondary, + fontSize: 14, + height: 1.4, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: Text('Cancel', + style: TextStyle(color: context.textSecondary)), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + style: FilledButton.styleFrom( + backgroundColor: Colors.red, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20)), + ), + child: const Text('I Understand, Continue', + style: TextStyle(fontFamily: 'ProductSans')), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + + await _runWithBottomSheet(); + } + + // Runs the operation off the main isolate (pool) and surfaces progress as a + // modal bottom sheet — same pattern as gallery_vault_screen batch ops. The + // sheet stays open during the await, then shows a Done state on success. + Future _runWithBottomSheet() async { + setState(() => _isRunning = true); + + final progressState = ValueNotifier( + OperationProgressState( + totalFiles: _selectedIds.length, + currentFileName: 'Preparing...', + statusMessage: 'Starting...', + isProcessing: true, + isEncrypting: _isEncrypt, + ), + ); + + if (!mounted) { + progressState.dispose(); + return; + } + + void onProgress( + int current, int total, String name, int processed, int totalBytes, + ) { + progressState.value = progressState.value.copyWith( + totalFiles: total, + currentFile: current, + currentFileName: name, + totalSizeBytes: totalBytes, + processedSizeBytes: processed, + statusMessage: current >= total + ? 'Finalizing...' + : 'Processing ${current + 1} of $total...', + ); + } + + final sheetFuture = showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + isDismissible: false, + enableDrag: false, + builder: (ctx) => ValueListenableBuilder( + valueListenable: progressState, + builder: (context, state, _) => OperationProgressSheet( + operationType: + _isEncrypt ? OperationType.encrypt : OperationType.decrypt, + totalFiles: state.totalFiles, + currentFile: state.currentFile, + currentFileName: state.currentFileName, + totalSizeBytes: state.totalSizeBytes, + processedSizeBytes: state.processedSizeBytes, + statusMessage: state.statusMessage, + isProcessing: state.isProcessing, + isComplete: state.isComplete, + isEncrypting: state.isEncrypting, + ), + ), + ); + + final vaultService = ref.read(vaultServiceProvider); + final result = _isEncrypt + ? await vaultService.encryptVaultFiles( + widget.algorithm, + fileFilter: _selectedIds, + onProgress: onProgress, + ) + : await vaultService.removeEncryption( + fileFilter: _selectedIds, + onProgress: onProgress, + ); + + if (!mounted) { + progressState.dispose(); + return; + } + + ref.invalidate(vaultNotifierProvider); + + if (result < 0) { + Navigator.of(context).pop(); // close sheet + progressState.dispose(); + setState(() => _isRunning = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + '$_actionVerb failed', + style: const TextStyle(fontFamily: 'ProductSans'), + ), + ), + ); + return; + } + + progressState.value = progressState.value.copyWith( + isComplete: true, + isProcessing: false, + currentFile: progressState.value.totalFiles, + processedSizeBytes: progressState.value.totalSizeBytes, + statusMessage: 'Completed successfully', + ); + + await sheetFuture; // user taps Done + progressState.dispose(); + if (!mounted) return; + setState(() => _isRunning = false); + Navigator.of(context).pop(); // pop picker, return to settings + } +} From fc022ad3808e1de69e29bcf49bb79aebfd88b1b6 Mon Sep 17 00:00:00 2001 From: ultraelectronica Date: Wed, 1 Jul 2026 03:29:44 +0800 Subject: [PATCH 30/34] Add shared FileInfoSheet bottom sheet widget --- lib/widgets/file_info_sheet.dart | 152 +++++++++++++++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 lib/widgets/file_info_sheet.dart diff --git a/lib/widgets/file_info_sheet.dart b/lib/widgets/file_info_sheet.dart new file mode 100644 index 0000000..8e4cff6 --- /dev/null +++ b/lib/widgets/file_info_sheet.dart @@ -0,0 +1,152 @@ +import 'package:flutter/material.dart'; +import '../models/vaulted_file.dart'; +import '../themes/app_colors.dart'; + +/// Shared file-info bottom sheet. Renders the common rows every viewer shows +/// (Name, Type, Size, Added, Last Viewed) plus an Encryption section with the +/// algorithm / salt / iterations / IV when the file is encrypted. Callers pass +/// [extraRows] for screen-specific data (Pages, Tags, Views). +class FileInfoSheet { + static Future show( + BuildContext context, + VaultedFile file, { + String? title, + String? typeLabel, + List<(String, String)>? extraRows, + }) { + return showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + isScrollControlled: true, + builder: (ctx) => Container( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(ctx).size.height * 0.75, + ), + decoration: BoxDecoration( + color: ctx.backgroundColor, + borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), + ), + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 40, + height: 4, + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: ctx.dividerColor, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + Text( + title ?? 'File Information', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: ctx.textPrimary, + fontFamily: 'ProductSans', + ), + ), + const SizedBox(height: 16), + _row(ctx, 'Name', file.originalName), + _row(ctx, 'Type', typeLabel ?? file.extension.toUpperCase()), + _row(ctx, 'Size', file.formattedSize), + _row(ctx, 'Added', file.formattedDateAdded), + if (file.lastViewed != null) + _row(ctx, 'Last Viewed', _formatDateTime(file.lastViewed!)), + const _SectionDivider(label: 'Encryption'), + _row( + ctx, + 'Status', + file.isEncrypted ? 'Encrypted' : 'Not encrypted', + ), + if (file.isEncrypted) ...[ + _row( + ctx, + 'Algorithm', + (file.encryptionAlgorithm?.displayName) ?? 'AES-256', + ), + if (file.kdfIterations != null) + _row(ctx, 'Iterations', '${file.kdfIterations}'), + if (file.keyDerivationSalt != null) + _row(ctx, 'Key Salt', file.keyDerivationSalt!), + if (file.encryptionIv != null) + _row(ctx, 'IV', file.encryptionIv!), + ], + if (extraRows != null) + for (final r in extraRows) _row(ctx, r.$1, r.$2), + SizedBox(height: MediaQuery.of(ctx).padding.bottom + 8), + ], + ), + ), + ), + ), + ); + } + + static Widget _row(BuildContext context, String label, String value) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 100, + child: Text( + label, + style: TextStyle( + fontFamily: 'ProductSans', + color: context.textTertiary, + fontSize: 14, + ), + ), + ), + Expanded( + child: Text( + value, + style: TextStyle( + fontFamily: 'ProductSans', + color: context.textPrimary, + fontSize: 14, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); + } + + static String _formatDateTime(DateTime d) { + String two(int n) => n.toString().padLeft(2, '0'); + return '${d.day}/${d.month}/${d.year} ${two(d.hour)}:${two(d.minute)}'; + } +} + +class _SectionDivider extends StatelessWidget { + final String label; + const _SectionDivider({required this.label}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(top: 16, bottom: 4), + child: Text( + label, + style: TextStyle( + fontFamily: 'ProductSans', + fontWeight: FontWeight.w600, + color: context.textSecondary, + fontSize: 13, + ), + ), + ); + } +} From d1be5d86fb89c0ec8b5046482e4615b4182df771 Mon Sep 17 00:00:00 2001 From: ultraelectronica Date: Wed, 1 Jul 2026 03:41:40 +0800 Subject: [PATCH 31/34] Use theme-aware accent and success colors --- lib/widgets/operation_progress_sheet.dart | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/lib/widgets/operation_progress_sheet.dart b/lib/widgets/operation_progress_sheet.dart index 9efa99d..ec7c753 100644 --- a/lib/widgets/operation_progress_sheet.dart +++ b/lib/widgets/operation_progress_sheet.dart @@ -109,7 +109,7 @@ class _OperationProgressSheetState extends State { child: ElevatedButton( onPressed: () => Navigator.pop(context), style: ElevatedButton.styleFrom( - backgroundColor: AppColors.accent, + backgroundColor: context.accentColor, padding: const EdgeInsets.symmetric(vertical: 16), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), @@ -401,17 +401,19 @@ class _OperationProgressSheetState extends State { } Widget _buildCompleteState() { + final successColor = + context.isDarkMode ? AppColors.darkSuccess : AppColors.success; return Column( children: [ Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( - color: Colors.green.withValues(alpha: 0.1), + color: successColor.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(12), ), child: Row( children: [ - const Icon(Icons.check_circle, color: Colors.green, size: 32), + Icon(Icons.check_circle, color: successColor, size: 32), const SizedBox(width: 12), Expanded( child: Text( From ed2b7db29cf79fb4c587c3a7c62fbc4007ea79de Mon Sep 17 00:00:00 2001 From: ultraelectronica Date: Wed, 1 Jul 2026 03:41:47 +0800 Subject: [PATCH 32/34] Remove elevated button shadow in operation progress sheet --- lib/widgets/operation_progress_sheet.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/widgets/operation_progress_sheet.dart b/lib/widgets/operation_progress_sheet.dart index ec7c753..4b05811 100644 --- a/lib/widgets/operation_progress_sheet.dart +++ b/lib/widgets/operation_progress_sheet.dart @@ -110,6 +110,7 @@ class _OperationProgressSheetState extends State { onPressed: () => Navigator.pop(context), style: ElevatedButton.styleFrom( backgroundColor: context.accentColor, + elevation: 0, padding: const EdgeInsets.symmetric(vertical: 16), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), From 56946c89340e695e3b17345508653714887c1df6 Mon Sep 17 00:00:00 2001 From: ultraelectronica Date: Wed, 1 Jul 2026 03:49:32 +0800 Subject: [PATCH 33/34] Remove redundant elevation override from button style --- lib/widgets/operation_progress_sheet.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/widgets/operation_progress_sheet.dart b/lib/widgets/operation_progress_sheet.dart index 4b05811..ec7c753 100644 --- a/lib/widgets/operation_progress_sheet.dart +++ b/lib/widgets/operation_progress_sheet.dart @@ -110,7 +110,6 @@ class _OperationProgressSheetState extends State { onPressed: () => Navigator.pop(context), style: ElevatedButton.styleFrom( backgroundColor: context.accentColor, - elevation: 0, padding: const EdgeInsets.symmetric(vertical: 16), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), From 6345c9bbf5f2f65851674d6c21847291b8b1308a Mon Sep 17 00:00:00 2001 From: ultraelectronica Date: Wed, 1 Jul 2026 03:50:29 +0800 Subject: [PATCH 34/34] Remove default elevation from done button --- lib/widgets/operation_progress_sheet.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/widgets/operation_progress_sheet.dart b/lib/widgets/operation_progress_sheet.dart index ec7c753..4b05811 100644 --- a/lib/widgets/operation_progress_sheet.dart +++ b/lib/widgets/operation_progress_sheet.dart @@ -110,6 +110,7 @@ class _OperationProgressSheetState extends State { onPressed: () => Navigator.pop(context), style: ElevatedButton.styleFrom( backgroundColor: context.accentColor, + elevation: 0, padding: const EdgeInsets.symmetric(vertical: 16), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12),