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. 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); 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) 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 ========== 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/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, + ), + ], + ), + ); + } +} 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/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, ); } 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 + } +} 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), 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/gallery_vault_screen.dart b/lib/screens/gallery_vault_screen.dart index 104370e..26ce9cd 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'; @@ -19,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'; @@ -43,6 +45,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 +82,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 +107,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 +164,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 +269,9 @@ class _GalleryVaultScreenState extends ConsumerState case 'settings': _openSettingsScreen(); break; + case 'customize_bar': + _showCustomizeBarSheet(); + break; case 'refresh': ref.read(vaultNotifierProvider.notifier).refresh(); break; @@ -267,6 +308,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 +347,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 +361,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 +410,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 +1256,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 +1333,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 +1394,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 @@ -1502,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) { @@ -1779,42 +1941,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 +1972,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 +1988,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 +2003,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 +2023,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 +2038,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 +2053,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 +2067,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 +2164,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 +2263,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, + ), ], ), ), 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, ); } 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/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( 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 } } 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/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, - ), - ), - ], - ), - ), - ), - ), - ); - } -} 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; diff --git a/lib/services/encryption_service.dart b/lib/services/encryption_service.dart index ea69caa..a102a14 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; @@ -1486,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_'); @@ -1509,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) { @@ -1523,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; @@ -1538,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'; 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'), + ), + ), + ], + ); + }, + ), + ), + ); + } +} 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 { diff --git a/lib/services/vault_service.dart b/lib/services/vault_service.dart index adaf480..e2e1b0d 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!); } @@ -2209,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 { @@ -2247,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, @@ -2279,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 ?? '', @@ -2288,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; @@ -2308,6 +2376,9 @@ class VaultService { ); reEncryptedCount++; } + processedBytes += file.fileSize; + onProgress?.call(i + 1, encryptedFiles.length, file.originalName, + processedBytes, totalBytes); } if (isDecoy) { @@ -2325,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 { 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), ), diff --git a/lib/widgets/animated_latch_logo.dart b/lib/widgets/animated_latch_logo.dart new file mode 100644 index 0000000..82be239 --- /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 = 0.50; + const dotDur = 0.25; + 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/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, ), ], ), diff --git a/lib/widgets/explorer_file_grid.dart b/lib/widgets/explorer_file_grid.dart index 3a4e337..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'; @@ -61,11 +62,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 +71,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 +80,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 +313,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, @@ -889,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) { 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; }, ), 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, + ), + ), + ); + } +} diff --git a/lib/widgets/operation_progress_sheet.dart b/lib/widgets/operation_progress_sheet.dart index 49864eb..4b05811 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; @@ -109,7 +109,8 @@ class _OperationProgressSheetState extends State { child: ElevatedButton( onPressed: () => Navigator.pop(context), style: ElevatedButton.styleFrom( - backgroundColor: AppColors.accent, + backgroundColor: context.accentColor, + elevation: 0, padding: const EdgeInsets.symmetric(vertical: 16), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), @@ -163,6 +164,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 +205,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'; } } @@ -389,17 +402,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( 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)); + }); +}