// lib/main.dart import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'dart:math'; import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:path/path.dart' as p; import 'package:archive/archive.dart'; import 'package:file_selector/file_selector.dart'; import 'package:watcher/watcher.dart'; import 'package:crypto/crypto.dart' as crypto; import 'helpers/database_helper.dart'; // Global ScaffoldMessenger key to allow showing SnackBars even during early lifecycle final GlobalKey rootMessengerKey = GlobalKey(); // Global Navigator key so dialogs can be shown from _MyAppState (which is above MaterialApp) final GlobalKey rootNavigatorKey = GlobalKey(); // ── Secure config ───────────────────────────────────────────────────────────── // All secrets are stored in the OS keychain (macOS Keychain / Windows Credential // Locker) via flutter_secure_storage — never hardcoded or written to plain files. const _secureStorage = FlutterSecureStorage(); /// Returns the WS bridge API key. On first run a random 32-char key is generated /// and stored in the keychain. You must set the SAME key on the server side. Future _getOrCreateApiKey() async { const storageKey = 'planxo_ws_api_key'; final stored = await _secureStorage.read(key: storageKey); if (stored != null && stored.isNotEmpty) return stored; // Generate a cryptographically random key on first run final rng = Random.secure(); final bytes = List.generate(24, (_) => rng.nextInt(256)); final key = base64UrlEncode(bytes).replaceAll('=', ''); await _secureStorage.write(key: storageKey, value: key); return key; } /// Returns this machine's unique client ID. Generated once and stored in keychain. Future _getOrCreateUniqueId() async { const storageKey = 'planxo_unique_id'; final stored = await _secureStorage.read(key: storageKey); if (stored != null && stored.isNotEmpty) return stored; final rng = Random.secure(); final bytes = List.generate(16, (_) => rng.nextInt(256)); final uid = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); await _secureStorage.write(key: storageKey, value: uid); return uid; } // Runtime values — populated in main() before the app starts String apiKey = ''; String uniqueId = ''; // Dynamic URLs based on client name String wsUrl = 'wss://cms.techpremedia.com/ws'; String backendUrl = 'https://cms.techpremedia.com'; String assetManagerUrl = 'https://cms.techpremedia.com/assets-api'; final Dio dio = Dio(); // Asset manager shared secret — loaded from keychain at startup. // Must match ASSET_MANAGER_SECRET on the server. String _assetManagerSecret = ''; void main() async { WidgetsFlutterBinding.ensureInitialized(); // Load / generate secrets before the UI starts apiKey = await _getOrCreateApiKey(); uniqueId = await _getOrCreateUniqueId(); _assetManagerSecret = await _secureStorage.read(key: 'planxo_asset_manager_secret') ?? ''; // Inject the secret into every Dio request to the asset manager via interceptor dio.interceptors.add(InterceptorsWrapper( onRequest: (options, handler) { if (_assetManagerSecret.isNotEmpty) { options.headers['X-Internal-Token'] = _assetManagerSecret; } return handler.next(options); }, )); runApp(const MyApp()); } class FileItem { final String key; // S3 key or object identifier final String name; // display filename final int? size; // optional bytes // runtime state: bool isDownloading = false; int received = 0; int total = 0; CancelToken? cancelToken; String? localPath; // if downloaded, path on disk FileItem({ required this.key, required this.name, this.size, }); } class ChangeItem { final String path; final String status; // 'new_local', 'modified_local', 'new_remote' final File? localFile; final FileItem? remoteItem; ChangeItem(this.path, this.status, {this.localFile, this.remoteItem}); } class MyApp extends StatefulWidget { const MyApp({super.key}); @override State createState() => _MyAppState(); } class _MyAppState extends State with SingleTickerProviderStateMixin { bool _isLoginInProgress = false; List files = []; List folders = []; // Local folders detected on disk (full paths) List localFolders = []; bool loading = false; String? error; // Client login state final TextEditingController _clientController = TextEditingController(); String _clientName = ''; String _authKey = ''; Map? _userInfo; bool _isAuthenticated = false; int _syncInterval = 15; // Sync interval in minutes from backend Timer? _periodicSyncTimer; // Storage base path (where we create ProjectsCache / Downloads). Persisted. String _storageBasePath = ''; final TextEditingController _storageController = TextEditingController(); // Sync settings: stored as map folder -> { enabled: bool, interval: int } Map _syncSettings = {}; final Map _syncTimers = {}; final Map _syncRunning = {}; // Extraction preference: when true, extract into storage root using folder path bool _extractToRoot = true; // Auto auth polling to avoid manual "Check Auth" button Timer? _authPollTimer; // Debounce timer for post-upload file list refresh (avoids cascade on batch uploads) Timer? _refreshDebounceTimer; // Per-file debounce timers: fire only after the file has been stable for 5 s. // This prevents uploading partial/temp files created by Illustrator, Photoshop, etc. final Map _uploadDebounceTimers = {}; int _authPollAttempts = 0; // Track repetitive auth errors to avoid noisy logs String? _lastAuthError; int _lastAuthErrorCount = 0; // WebSocket members WebSocket? _ws; bool _wsOpen = false; bool _wsConnecting = false; DateTime? _lastWsAttempt; final Map>> _pendingListRequests = {}; final Map> _pendingPresign = {}; // Folder sync state Directory? _currentProjectDir; // local root for extracted folder String? _currentFolderPrefix; // e.g. 'projects//' StreamSubscription? _watchSub; // legacy single-watcher (kept for compat) final Map _watchSubs = {}; // multi-folder watchers final Map _lastUpload = {}; final Set _knownS3Keys = {}; // populated from API for the folder final Map _lastUploadedHash = {}; // localPath -> sha256 final Map _folderDownloading = {}; // folder -> in-progress final Map _lastFolderDownloadTs = {}; // folder -> last ts final DatabaseHelper _db = DatabaseHelper(); // Sync log state List> _recentEvents = []; bool _logLoading = false; bool _logFilterCurrentOnly = true; // Track last synced folder for easy resume String? _lastSyncedFolder; String? _lastSyncedLocalPath; // Store sync metadata: localPath -> {modified, hash} final Map> _syncMeta = {}; // Changes state List _changes = []; List _remoteMissingFiles = []; // Added missing field late TabController _tabController; @override void initState() { super.initState(); _tabController = TabController(length: 2, vsync: this); // load sync settings from disk _loadSyncSettings(); _loadSyncMeta(); // Load persisted meta // load saved client name and auth key _loadClientName(); _loadAuthKey(); // load storage path _loadStoragePath(); } @override void dispose() { _tabController.dispose(); _ws?.close(); _watchSub?.cancel(); _authPollTimer?.cancel(); _refreshDebounceTimer?.cancel(); _periodicSyncTimer?.cancel(); _clientController.dispose(); _storageController.dispose(); // cancel any active sync timers for (final t in _syncTimers.values) { if (t.isActive) t.cancel(); } // cancel any pending per-file upload debounce timers for (final t in _uploadDebounceTimers.values) { if (t.isActive) t.cancel(); } _uploadDebounceTimers.clear(); super.dispose(); } // --- Added missing methods --- Future _syncMetaFile() async { final home = Platform.environment['HOME'] ?? Directory.current.path; final f = File(p.join(home, '.planxo_sync_meta.json')); if (!await f.exists()) await f.create(recursive: true); return f; } Future _loadSyncMeta() async { try { final f = await _syncMetaFile(); final content = await f.readAsString(); if (content.trim().isEmpty) return; final Map data = jsonDecode(content); data.forEach((k, v) { if (v is Map) _syncMeta[k] = Map.from(v); }); } catch (e) { print('Failed to load sync meta: $e'); } } Future _saveSyncMeta() async { try { final f = await _syncMetaFile(); await f.writeAsString(jsonEncode(_syncMeta)); } catch (e) { print('Failed to save sync meta: $e'); } } void _detectRemoteMissingFiles(List list) { _remoteMissingFiles.clear(); if (_currentFolderPrefix == null || _currentProjectDir == null) { return; } final prefix = _currentFolderPrefix!; for (final fi in list) { if (!fi.key.startsWith(prefix)) continue; final rel = fi.key.substring(prefix.length); final localCandidate = p.join(_currentProjectDir!.path, rel); if (!File(localCandidate).existsSync()) { _remoteMissingFiles.add(fi); } } } Future _checkForChanges() async { if (_currentProjectDir == null || _currentFolderPrefix == null) return; final localDir = _currentProjectDir!; final prefix = _currentFolderPrefix!; final newChanges = []; final remoteMap = {}; // Index remote files by relative path for (var f in files) { if (f.key.startsWith(prefix)) { final rel = f.key.substring(prefix.length); remoteMap[rel] = f; } } // Scan local if (await localDir.exists()) { await for (final entity in localDir.list(recursive: true)) { if (entity is File) { final rel = p.relative(entity.path, from: localDir.path).replaceAll('\\', '/'); if (p.basename(rel) == '.DS_Store' || p.basename(rel) == '.keep') continue; final remote = remoteMap[rel]; if (remote == null) { newChanges.add(ChangeItem(rel, 'new_local', localFile: entity)); } else { // Check modification final meta = _syncMeta[entity.path]; final stat = await entity.stat(); bool isModified = false; if (meta == null) { // No sync record. Check size as proxy if available if (remote.size != null && remote.size != stat.size) { isModified = true; } } else { // Check timestamp first if (stat.modified.millisecondsSinceEpoch != meta['lastModified']) { // Timestamp changed, check hash final currentHash = await _hashFile(entity.path); if (currentHash != meta['hash']) { isModified = true; } else { // Update meta timestamp to avoid re-hashing next time _syncMeta[entity.path]!['lastModified'] = stat.modified.millisecondsSinceEpoch; _saveSyncMeta(); } } } if (isModified) { newChanges.add(ChangeItem(rel, 'modified_local', localFile: entity, remoteItem: remote)); } else { // Synced newChanges.add(ChangeItem(rel, 'synced', localFile: entity, remoteItem: remote)); } remoteMap.remove(rel); // Handled } } } } // Remaining remote files are missing locally remoteMap.forEach((rel, item) { newChanges.add(ChangeItem(rel, 'new_remote', remoteItem: item)); }); // Sort: pending first newChanges.sort((a, b) { final aSynced = a.status == 'synced'; final bSynced = b.status == 'synced'; if (aSynced && !bSynced) return 1; if (!aSynced && bSynced) return -1; return a.path.compareTo(b.path); }); setState(() { _changes = newChanges; }); } // ── App-managed storage (hidden, protected) ────────────────────────────────── // The storage folder is ALWAYS ~/Library/Application Support/PlanXO/. // It is hidden from Finder via `chflags hidden` and the root folder is // made user-immutable via `chflags uchg` so users cannot delete or rename it. // The app temporarily lifts the immutable flag before writing, then restores it. /// Canonical managed path — always ~/Library/Application Support/PlanXO String get _managedStoragePath { final home = Platform.environment['HOME'] ?? Directory.current.path; return p.join(home, 'Library', 'Application Support', 'PlanXO'); } /// Remove user-immutable flag so the app can write to the folder. Future _unlockStorage() async { try { await Process.run('chflags', ['nouchg', _managedStoragePath]); } catch (_) {} } /// Re-apply hidden + user-immutable flags. Future _lockStorage() async { try { await Process.run('chflags', ['hidden', _managedStoragePath]); await Process.run('chflags', ['uchg', _managedStoragePath]); } catch (_) {} } Future _loadStoragePath() async { final managed = _managedStoragePath; // One-time migration: if old Downloads/PlanXO (or any previously saved path) // has data, move it into the managed location. await _migrateOldStorage(managed); // Always use the managed path — ignore any previously saved custom path. _storageBasePath = managed; _storageController.text = managed; setState(() {}); // Ensure the folder exists and is hidden/protected. await _ensureStorageBaseExists(); // Populate local folders from the managed directory immediately. await _scanStorageFolders(); } /// Migrate data from old location (~/Downloads/PlanXO or saved path) into /// the managed location, then delete the old location. Future _migrateOldStorage(String managedPath) async { final home = Platform.environment['HOME'] ?? Directory.current.path; final candidates = [ p.join(home, 'Downloads', 'PlanXO'), p.join(home, 'PlanXO'), ]; // Also check previously saved path try { final legacyFile = File(p.join(home, '.planxo_storage')); if (await legacyFile.exists()) { final saved = (await legacyFile.readAsString()).trim(); if (saved.isNotEmpty && !candidates.contains(saved)) { candidates.add(saved); } // Remove the legacy config file — path is now fixed. await legacyFile.delete(); } } catch (_) {} for (final oldPath in candidates) { final oldDir = Directory(oldPath); if (await oldDir.exists() && oldPath != managedPath) { try { await _unlockStorage(); final newDir = Directory(managedPath); if (!await newDir.exists()) await newDir.create(recursive: true); // Copy all contents recursively await for (final entity in oldDir.list(recursive: true, followLinks: false)) { final rel = p.relative(entity.path, from: oldPath); final dest = p.join(managedPath, rel); if (entity is Directory) { await Directory(dest).create(recursive: true); } else if (entity is File) { await Directory(p.dirname(dest)).create(recursive: true); await entity.copy(dest); } } // Remove old location after successful copy await oldDir.delete(recursive: true); _showSnack('Migrated existing data to managed storage.'); } catch (e) { print('Storage migration failed: $e'); } break; // only migrate first found } } } // _saveStoragePath is kept for internal use but no longer exposed to users. Future _saveStoragePath(String path) async { _storageBasePath = path; _storageController.text = path; } Future _ensureStorageBaseExists() async { try { await _unlockStorage(); final d = Directory(_storageBasePath); if (!await d.exists()) { await d.create(recursive: true); } await _lockStorage(); } catch (e) { print('Failed to initialise managed storage: $e'); } } Future _isPathWritable(String dirPath) async { try { await _unlockStorage(); final testFile = File(p.join(dirPath, '.planxo_write_test')); await testFile.writeAsString('ok', flush: true); await testFile.delete(); await _lockStorage(); return true; } catch (e) { return false; } } // _pickStorageFolder is no longer exposed — storage path is app-managed. Future _pickStorageFolder() async { // No-op: path is now fixed to the managed location. } // ── Secure credential storage (OS keychain) ───────────────────────────────── // All secrets go through _secureStorage — no plaintext files on disk. Future _loadClientName() async { try { final name = await _secureStorage.read(key: 'planxo_client_name'); if (name != null && name.trim().isNotEmpty) { _clientName = name.trim(); _clientController.text = _clientName; _updateUrls(_clientName); setState(() {}); } } catch (e) { print('Failed to load client name: $e'); } } void _updateUrls(String clientName) { if (clientName.isEmpty) return; final subdomain = clientName.trim().toLowerCase(); wsUrl = 'wss://$subdomain.techpremedia.com/ws'; backendUrl = 'https://$subdomain.techpremedia.com'; assetManagerUrl = 'https://$subdomain.techpremedia.com/assets-api'; print('Updated URLs for client $subdomain: $backendUrl'); } Future _saveClientName(String name) async { try { await _secureStorage.write(key: 'planxo_client_name', value: name); } catch (e) { print('Failed to save client name: $e'); } } Future _loadAuthKey() async { try { final key = await _secureStorage.read(key: 'planxo_auth_key'); if (key != null && key.trim().isNotEmpty) { _authKey = key.trim(); setState(() {}); } } catch (e) { print('Failed to load auth key: $e'); } } Future _saveAuthKey(String key) async { try { await _secureStorage.write(key: 'planxo_auth_key', value: key); _authKey = key; setState(() {}); } catch (e) { print('Failed to save auth key: $e'); } } Future _clearAuthKey() async { try { await _secureStorage.delete(key: 'planxo_auth_key'); _authKey = ''; _userInfo = null; _isAuthenticated = false; _periodicSyncTimer?.cancel(); _periodicSyncTimer = null; setState(() {}); } catch (e) { print('Failed to clear auth key: $e'); } } void _startPeriodicSync() { // Cancel any existing periodic sync timer _periodicSyncTimer?.cancel(); if (_syncInterval <= 0) return; print('Starting periodic sync every $_syncInterval minutes'); // Run sync immediately on start _performPeriodicSync(); // Then schedule periodic syncs _periodicSyncTimer = Timer.periodic(Duration(minutes: _syncInterval), (timer) { _performPeriodicSync(); }); } Future _performPeriodicSync() async { if (!_isAuthenticated || _currentProjectDir == null || _currentFolderPrefix == null) { print('Skipping periodic sync - not ready (auth: $_isAuthenticated, project: ${_currentProjectDir != null})'); return; } // Guard: only sync specific job folders (depth ≥ 3, e.g. "clients/ClientName/JobID/") // A too-broad prefix like "clients/" would download the entire client tree. final prefixDepth = _currentFolderPrefix!.split('/').where((s) => s.isNotEmpty).length; if (prefixDepth < 3) { print('Skipping periodic sync — prefix too broad ($_currentFolderPrefix), depth=$prefixDepth < 3'); return; } print('⏰ Periodic sync triggered for $_currentFolderPrefix'); try { // 1. Check for local changes and upload int uploads = await _syncLocalChanges(); // 2. Check for remote changes and download int downloads = await _syncRemoteChanges(); if (uploads == 0 && downloads == 0) { _showSnack('Sync complete: Up to date.'); } else { _showSnack('Sync complete: Uploaded $uploads, Downloaded $downloads files.'); } await _logEvent( eventType: 'periodic_sync', folder: _currentFolderPrefix!, status: 'ok', message: 'Periodic sync completed successfully' ); } catch (e) { print('Periodic sync error: $e'); await _logEvent( eventType: 'periodic_sync', folder: _currentFolderPrefix!, status: 'error', message: 'Periodic sync failed: $e' ); } } Future _syncLocalChanges() async { if (_currentProjectDir == null || _currentFolderPrefix == null) return 0; int uploadCount = 0; final localFiles = {}; final dir = _currentProjectDir!; if (!await dir.exists()) return 0; await for (final entity in dir.list(recursive: true)) { if (entity is File) { final rel = p.relative(entity.path, from: dir.path); final basename = p.basename(entity.path); // Ignore system files if (basename == '.DS_Store' || basename == '.keep') continue; localFiles[rel] = entity; } } // Check each local file for changes for (final entry in localFiles.entries) { final relPath = entry.key; final file = entry.value; final path = file.path; // Check sync meta final stat = await file.stat(); final lastModified = stat.modified.millisecondsSinceEpoch; final meta = _syncMeta[path]; if (meta != null && meta['lastModified'] == lastModified) { continue; } final bytes = await file.readAsBytes(); final hash = crypto.sha256.convert(bytes).toString(); if (meta != null && meta['hash'] == hash) { // Update timestamp only _syncMeta[path]!['lastModified'] = lastModified; _saveSyncMeta(); continue; } print('Uploading modified file: $relPath'); await _uploadFile(file, relPath); uploadCount++; } return uploadCount; } Future _syncRemoteChanges() async { if (_currentFolderPrefix == null) return 0; // Re-download the folder to get latest changes // This will extract new/updated files print('Checking for remote changes in $_currentFolderPrefix'); return await _downloadAndExtractFolder(_currentFolderPrefix!); } Future _uploadFile(File file, String relativePath) async { if (_currentProjectDir == null || _currentFolderPrefix == null) return; await _onLocalFileChanged(file.path, _currentFolderPrefix!); await _checkForChanges(); // Refresh changes list after upload } Future _checkAuthentication() async { if (_authKey.isEmpty) { _isAuthenticated = false; setState(() {}); return; } // If already authenticated, skip redundant checks if (_isAuthenticated) { return; } if (!_wsOpen) { _showSnack('WebSocket not connected. Cannot validate authentication.'); return; } // Send auth validation request via WebSocket final msg = jsonEncode({ 'type': 'validate_auth', 'data': {'auth_key': _authKey} }); _ws!.add(msg); } void _handleAuthResult(Map authData) { if (authData.containsKey('user')) { _userInfo = authData['user']; _isAuthenticated = true; _authPollTimer?.cancel(); _authPollTimer = null; // Get sync interval from auth data if (authData.containsKey('sync_interval')) { _syncInterval = authData['sync_interval'] as int; } _showSnack('✅ Authenticated as ${_userInfo!['username']} (${_userInfo!['role']}) - Sync: ${_syncInterval}min'); setState(() {}); // Now we can fetch files fetchFileList(); // Start periodic sync timer _startPeriodicSync(); } else { _isAuthenticated = false; _userInfo = null; _showSnack('❌ Authentication failed: ${authData['message'] ?? 'Unknown error'}'); setState(() {}); } } void _handleAuthApproval(Map approvalData) { final authKey = approvalData['auth_key']; final user = approvalData['user']; if (authKey != null && user != null) { _userInfo = user; _isAuthenticated = true; _authKey = authKey; _saveAuthKey(authKey); // Stop polling upon success _authPollTimer?.cancel(); _authPollTimer = null; // Get sync interval from approval data if (approvalData.containsKey('sync_interval')) { _syncInterval = approvalData['sync_interval'] as int; } _showSnack('🎉 Authorization approved by ${user['username']}! Sync: ${_syncInterval}min'); setState(() {}); // Fetch files now that we're authorized fetchFileList(); // Start periodic sync timer _startPeriodicSync(); } } void _startAuthPolling() { // Cancel any existing poller _authPollTimer?.cancel(); _authPollAttempts = 0; // Poll every 2 seconds for up to 2 minutes _authPollTimer = Timer.periodic(const Duration(seconds: 2), (t) async { if (!mounted) { t.cancel(); return; } if (_isAuthenticated) { t.cancel(); return; } if (_wsOpen && _authKey.isNotEmpty) { try { await _checkAuthentication(); } catch (_) {} } _authPollAttempts++; if (_authPollAttempts >= 60) { t.cancel(); _showSnack('Authorization is taking longer than expected.'); } }); } Future _openUrl(String url) async { // Security: only open URLs that belong to the configured backend domain. // This prevents a malicious server response from opening arbitrary URLs. final uri = Uri.tryParse(url); if (uri == null || !(uri.scheme == 'https' || uri.scheme == 'http')) { print('_openUrl blocked — not a valid http/https URL: $url'); _showSnack('Cannot open URL: invalid format'); return; } final allowedHost = Uri.tryParse(backendUrl)?.host ?? ''; if (allowedHost.isNotEmpty && uri.host != allowedHost) { print('_openUrl blocked — host ${uri.host} != expected $allowedHost'); _showSnack('Cannot open URL: unexpected domain'); return; } try { // Try multiple methods to open URL // Method 1: Direct open command var result = await Process.run('open', [url]); if (result.exitCode == 0) { print('Successfully opened URL with open command'); return; } // Method 2: Try with specific browser result = await Process.run('open', ['-a', 'Safari', url]); if (result.exitCode == 0) { print('Successfully opened URL with Safari'); return; } // Method 3: Try with Chrome result = await Process.run('open', ['-a', 'Google Chrome', url]); if (result.exitCode == 0) { print('Successfully opened URL with Chrome'); return; } // If all methods fail print('All browser opening methods failed'); _showSnack('Please manually open: $url'); } catch (e) { print('Error opening URL: $e'); _showSnack('Please manually open: $url'); } } // Generate a cryptographically secure auth key (256 bits of entropy) String _generateAuthKey() { final rng = Random.secure(); final bytes = List.generate(32, (_) => rng.nextInt(256)); return base64UrlEncode(bytes).replaceAll('=', ''); } Future _openLoginForClient(String client) async { if (client.trim().isEmpty) { _showSnack('Client name cannot be empty'); return; } // Prevent multiple login processes if (_isLoginInProgress) return; _isLoginInProgress = true; try { final newClientName = client.trim(); bool clientChanged = newClientName != _clientName; _clientName = newClientName; await _saveClientName(_clientName); _updateUrls(_clientName); // If client changed or WS not open, reconnect if (clientChanged && _wsOpen) { _ws!.close(); } // Try to connect WebSocket (non-blocking — if it fails we fall back to HTTP) if (!_wsOpen) { await _connectWs(); } // Generate unique auth key final authKey = _generateAuthKey(); await _saveAuthKey(authKey); // Register auth key — prefer WS, fall back to HTTP bool usedWs = false; if (_wsOpen && _ws != null) { final msg = jsonEncode({ 'type': 'register_auth_key', 'data': {'auth_key': authKey} }); try { _ws!.add(msg); usedWs = true; } catch (_) {} } if (!usedWs) { // WS unavailable — register via HTTP then open browser directly try { await dio.post( '$backendUrl/planxo/auth/register-key', data: {'auth_key': authKey}, ); } catch (_) { // Best-effort: server may not support this endpoint yet, continue anyway } _showSnack('Opening browser for authentication...'); final authUrl = '$backendUrl/planxo-auth/$authKey'; await _openUrl(authUrl); } else { // WS connected — browser will be opened when server sends 'auth_key_registered' _showSnack('Waiting for server to open browser...'); } setState(() {}); } catch (e) { _showSnack('Error during login: $e'); } finally { _isLoginInProgress = false; } } Future _enterAuthKey() async { // Check if the widget is still mounted and has proper context if (!mounted) return; try { final controller = TextEditingController(text: _authKey); final result = await showDialog( context: rootNavigatorKey.currentContext!, builder: (ctx) => AlertDialog( title: const Text('Enter Authorization Key'), content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text('Enter the authorization key you copied from the browser:'), const SizedBox(height: 16), TextField( controller: controller, decoration: const InputDecoration( labelText: 'Auth Key', hintText: 'Paste the key here...', border: OutlineInputBorder(), ), maxLines: 3, ), ], ), actions: [ TextButton( onPressed: () => Navigator.pop(ctx), child: const Text('Cancel'), ), TextButton( onPressed: () => Navigator.pop(ctx, controller.text.trim()), child: const Text('Save & Validate'), ), ], ), ); if (result != null && result.isNotEmpty) { await _saveAuthKey(result); await _checkAuthentication(); } } catch (e) { print('Error opening auth key dialog: $e'); _showSnack('Unable to open dialog. Please try again.'); } } /// Scan common locations for a `root/client/` directory and list its subfolders. Future _scanLocalClientFolders() async { setState(() { loading = true; }); try { final home = Platform.environment['HOME'] ?? ''; final candidates = []; candidates.add('/root/client'); if (home.isNotEmpty) candidates.add(p.join(home, 'root', 'client')); candidates.add(p.join(Directory.current.path, 'root', 'client')); String? found; for (final c in candidates) { final d = Directory(c); if (await d.exists()) { found = c; break; } } if (found == null) { _showSnack('No `root/client` folder found in checked locations.'); return; } final dir = Directory(found); final List foundDirs = []; await for (final e in dir.list()) { if (e is Directory) foundDirs.add(e.path); } if (foundDirs.isEmpty) { _showSnack('No subfolders found in $found'); return; } localFolders = foundDirs; setState(() {}); _showSnack('Found ${localFolders.length} local folders in $found'); } catch (e) { _showSnack('Failed to scan local folders: $e'); } finally { setState(() { loading = false; }); } } /// Scan the storage base path and populate [localFolders] with any /// subdirectories found (recursing one level into clients/ if present). /// This makes already-synced folders visible in the sidebar and center pane /// without requiring an API fetch. Future _scanStorageFolders() async { if (_storageBasePath.trim().isEmpty) return; final base = Directory(_storageBasePath); if (!await base.exists()) return; try { final found = {}; // Walk depth-1 and depth-2 to catch both flat and clients// structures await for (final e1 in base.list()) { if (e1 is! Directory) continue; final name1 = p.basename(e1.path); // Skip hidden or system dirs if (name1.startsWith('.') || name1 == 'ProjectsCache') continue; // If this looks like a wrapper folder (e.g. 'clients'), recurse one more level bool hasSubFolders = false; await for (final e2 in e1.list()) { if (e2 is Directory) { hasSubFolders = true; final name2 = p.basename(e2.path); if (name2.startsWith('.')) continue; // Recurse one more level for clients// bool hasDeep = false; await for (final e3 in e2.list()) { if (e3 is Directory && !p.basename(e3.path).startsWith('.')) { found.add(e3.path); hasDeep = true; } } if (!hasDeep) found.add(e2.path); } } if (!hasSubFolders) found.add(e1.path); } if (!mounted) return; setState(() { localFolders = found.toList()..sort(); }); } catch (e) { print('Failed to scan storage folders: \$e'); } } /// Use an existing local folder as the current project folder to watch and upload changes. Future _useLocalFolderAsProject(String fullPath) async { final dir = Directory(fullPath); if (!await dir.exists()) { _showSnack('Folder not found: $fullPath'); return; } _currentProjectDir = dir; // Derive the S3 folder prefix by stripping the storage base path. // e.g. storageBase = ".../PlanXO", fullPath = ".../PlanXO/clients/Test Client 1/1000014" // → prefix = "clients/Test Client 1/1000014/" // If the path is not under storageBase, fall back to just the basename. String folderPrefix; if (_storageBasePath.isNotEmpty && fullPath.startsWith(_storageBasePath)) { var rel = fullPath.substring(_storageBasePath.length); if (rel.startsWith('/')) rel = rel.substring(1); folderPrefix = rel.endsWith('/') ? rel : '$rel/'; } else { final name = p.basename(fullPath); folderPrefix = name.endsWith('/') ? name : '$name/'; } _currentFolderPrefix = folderPrefix; await _primeKnownKeys(_currentFolderPrefix!); _startWatcher(dir, _currentFolderPrefix!); _showSnack('Watching $fullPath and syncing to prefix $_currentFolderPrefix'); // Track for easy resume _lastSyncedFolder = _currentFolderPrefix; _lastSyncedLocalPath = fullPath; setState(() {}); } Future _connectWs() async { const retryDelay = Duration(seconds: 30); if (_wsOpen || _wsConnecting) return; // already connected/in-flight final base = Uri.parse(wsUrl); final uri = base.replace(queryParameters: { ...base.queryParameters, if (apiKey.isNotEmpty) 'apiKey': apiKey, if (uniqueId.isNotEmpty) 'unique_id': uniqueId, }); try { _wsConnecting = true; _lastWsAttempt = DateTime.now(); if (mounted) setState(() {}); print('Attempting WebSocket connect to: $uri'); _ws = await WebSocket.connect(uri.toString()).timeout(const Duration(seconds: 15)); _wsOpen = true; _wsConnecting = false; _ws!.listen( _onWsMessage, onDone: () { print('WebSocket closed by server'); _wsOpen = false; _ws = null; if (mounted) setState(() {}); if (mounted) Future.delayed(retryDelay, () { if (mounted) _connectWs(); }); }, onError: (err) { print('WebSocket error: $err'); _wsOpen = false; _ws = null; if (mounted) setState(() {}); if (mounted) Future.delayed(retryDelay, () { if (mounted) _connectWs(); }); }, cancelOnError: true, ); if (mounted) setState(() {}); print('Connected to WS at $uri'); return; } catch (e) { print('WebSocket connect failed to $uri: $e'); _wsOpen = false; _wsConnecting = false; _ws = null; if (mounted) setState(() {}); // Schedule reconnect only if still mounted if (mounted) Future.delayed(retryDelay, () { if (mounted) _connectWs(); }); } } void _onWsDone() { print('WebSocket closed by server'); _wsOpen = false; _authPollTimer?.cancel(); setState(() {}); // Optionally: schedule reconnect logic here } void _onWsError(dynamic err) { print('WebSocket error: $err'); _wsOpen = false; _authPollTimer?.cancel(); setState(() {}); // Optionally: schedule reconnect logic here } void _onWsMessage(dynamic raw) async { try { if (raw is String) { final Map msg = jsonDecode(raw); final type = msg['type'] as String?; final data = msg['data']; if (type == null) return; switch (type) { case 'auth_key_registered': // Auth key registered, open browser final authKey = data?['auth_key']; final browserUrl = data?['browser_url']; if (authKey != null && browserUrl != null) { _saveAuthKey(authKey); _showSnack('Auth key registered. Opening browser for approval...'); await _openUrl(browserUrl); _showSnack('Waiting for approval in browser...'); // Start polling to auto-detect approval _startAuthPolling(); } break; case 'auth_approved': // Browser approval received _handleAuthApproval(data ?? {}); break; case 'auth_success': _handleAuthResult(data ?? {}); break; case 'auth_revoked': // Handle logout/session expiration from server _handleAuthRevoked(data ?? {}); break; case 'auth_error': // Ignore stray errors after we are authenticated if (_isAuthenticated) { break; } _isAuthenticated = false; _userInfo = null; final errorMsg = data?['message'] ?? 'Authentication failed'; final errorDetails = data?['details'] ?? ''; final fullMessage = errorDetails.isNotEmpty ? '$errorMsg ($errorDetails)' : errorMsg; // Transient errors commonly seen during pending approval final isTransient = errorMsg == 'Invalid or expired auth key' || errorMsg == 'User not found'; // During polling, suppress noisy repeats of transient auth errors final pollingActive = _authPollTimer != null && _authPollTimer!.isActive; if (pollingActive && isTransient) { if (_lastAuthError != errorMsg) { _lastAuthError = errorMsg; _lastAuthErrorCount = 1; // Log once when message changes print('Auth transient: $errorMsg'); } else { _lastAuthErrorCount += 1; if (_lastAuthErrorCount % 10 == 0) { // Periodic heartbeat to show it's still happening print('Auth transient repeating: $errorMsg (x$_lastAuthErrorCount)'); } } setState(() {}); break; } // Only show snack for non-transient errors _showSnack('❌ Auth Error: $fullMessage'); print('Auth Error Details: $data'); setState(() {}); break; case 'connected': // server acknowledged print('Server connected: $data'); break; case 'download_folder': final folder = data['folder'] as String?; final projectId = data['projectId']; if (folder != null) { final last = _lastFolderDownloadTs[folder]; final now = DateTime.now(); if (_folderDownloading[folder] == true || (last != null && now.difference(last) < const Duration(seconds: 60))) { break; } _downloadAndExtractFolder(folder); } break; case 'files_list': // data expected to be list of {key,name,size} final List list = []; if (data is List) { for (final e in data) { final key = e['key'] as String? ?? ''; final name = e['name'] as String? ?? key.split('/').last; final size = e['size'] != null ? (e['size'] as num).toInt() : null; list.add(FileItem(key: key, name: name, size: size)); } } // complete any pending completer (if request made) _completeFileListPending(list); // update UI if not from pending (server push) if (mounted) { setState(() { files = list; loading = false; }); } break; case 'presigned_url': // data expected { key, url } final key = data != null ? data['key'] as String? : null; final url = data != null ? data['url'] as String? : null; if (key != null) _completePresignPending(key, url); break; case 'error': final msgText = data != null && data['message'] != null ? data['message'] : data; _showSnack('Server error: $msgText'); break; default: print('Unhandled WS message type: $type | data: $data'); } } } catch (e, st) { print('Failed to parse WS message: $e\n$st'); } } void _completeFileListPending(List list) { // find all pending list completers and complete them if (_pendingListRequests.isEmpty) return; final keys = List.from(_pendingListRequests.keys); for (final k in keys) { final c = _pendingListRequests.remove(k); if (c != null && !c.isCompleted) c.complete(list); } } void _completePresignPending(String key, String? url) { final completer = _pendingPresign.remove(key); if (completer != null && !completer.isCompleted) completer.complete(url); } Future fetchFileList() async { if (!_wsOpen) { // attempt HTTP fallback immediately await _fetchFileListHttpFallback(); return; } if (!_isAuthenticated) { _showSnack('Please authenticate first before accessing files'); return; } setState(() { loading = true; error = null; }); // use a requestId to be robust; server currently responds with 'files_list' final requestId = DateTime.now().millisecondsSinceEpoch.toString(); final completer = Completer>(); _pendingListRequests[requestId] = completer; // We send a 'list_files' message. Server should respond with 'files_list'. final msg = jsonEncode({'type': 'list_files', 'data': {'requestId': requestId}}); _ws!.add(msg); // Race between WS response and fallback HTTP after timeout List? wsResult; try { wsResult = await completer.future.timeout(const Duration(seconds: 6)); } catch (_) { // ignore, we'll fallback } if (wsResult != null) { // Also fetch role-filtered folders — the WS 'list_files' response only // returns file items, not the folders list. Without this, the sidebar and // center pane would stay empty even when files exist (the HTTP fallback // path populates folders, but the WS path did not). List filteredFolders = []; try { if (_isAuthenticated && _authKey.isNotEmpty) { final folderRes = await dio.get( '$backendUrl/planxo/folders/filtered', options: Options(headers: {'X-Auth-Key': _authKey}), ); if (folderRes.statusCode == 200) { // Server may return {"folders":[...]} or a raw list directly List rawList = []; if (folderRes.data is Map && folderRes.data['folders'] is List) { rawList = folderRes.data['folders'] as List; } else if (folderRes.data is List) { rawList = folderRes.data as List; } for (final f in rawList) { if (f is String) filteredFolders.add(f); else if (f is Map) { final val = f['folder'] ?? f['path'] ?? f['prefix'] ?? f['name']; if (val is String) filteredFolders.add(val); } } } } } catch (_) {} if (!mounted) return; setState(() { files = wsResult!; if (filteredFolders.isNotEmpty) folders = filteredFolders; loading = false; }); _detectRemoteMissingFiles(wsResult!); _checkForChanges(); // Check changes after fetch await _scanStorageFolders(); // refresh local folders from disk } else { await _fetchFileListHttpFallback(); } } Future _fetchFileListHttpFallback() async { setState(() { loading = true; error = null; }); try { // 1. Try to fetch filtered folders from backend first List filteredFolders = []; try { if (_isAuthenticated && _authKey.isNotEmpty) { final folderRes = await dio.get( '$backendUrl/planxo/folders/filtered', options: Options(headers: {'X-Auth-Key': _authKey}) ); if (folderRes.statusCode == 200) { // Server may return {"folders":[...]} or a raw list directly List rawList = []; if (folderRes.data is Map && folderRes.data['folders'] is List) { rawList = folderRes.data['folders'] as List; } else if (folderRes.data is List) { rawList = folderRes.data as List; } for (final f in rawList) { if (f is String) filteredFolders.add(f); else if (f is Map) { final val = f['folder'] ?? f['path'] ?? f['prefix'] ?? f['name']; if (val is String) filteredFolders.add(val); } } print('Fetched ${filteredFolders.length} filtered folders from backend'); } } } catch (e) { print('Failed to fetch filtered folders: $e'); } // If we got role-filtered folders, only fetch assets for those folders. // This prevents non-admin users from seeing files they're not assigned to. if (filteredFolders.isNotEmpty) { setState(() { folders = filteredFolders; loading = false; }); _checkForChanges(); await _scanStorageFolders(); return; } final envUrl = const String.fromEnvironment('ASSET_MANAGER_URL'); final baseUrl = envUrl.isNotEmpty ? envUrl : assetManagerUrl; print('Falling back to HTTP GET $baseUrl/assets'); final res = await dio.get('$baseUrl/assets'); final List list = []; final List foundFolders = []; if (res.statusCode == 200) { final data = res.data; // data can be array or object { assets: [], folders: [] } if (data is List) { for (final e in data) { final key = (e['s3_key'] as String?) ?? ''; final name = (e['filename'] as String?) ?? key.split('/').last; final size = e['size'] is num ? (e['size'] as num).toInt() : null; list.add(FileItem(key: key, name: name, size: size)); } } else if (data is Map) { // assets final assetsData = (data['assets'] is List) ? data['assets'] as List : (data['items'] is List ? data['items'] as List : []); for (final e in assetsData) { final key = (e['s3_key'] as String?) ?? e['key'] ?? ''; final name = (e['filename'] as String?) ?? (key.split('/').isNotEmpty ? key.split('/').last : key); final size = e['size'] is num ? (e['size'] as num).toInt() : null; list.add(FileItem(key: key, name: name, size: size)); } // folders (returned by asset_manager_app.py) final foldersData = (data['folders'] is List) ? data['folders'] as List : []; for (final f in foldersData) { final folderPath = f['folder'] as String? ?? f['prefix'] as String? ?? f['path'] as String?; if (folderPath != null) foundFolders.add(folderPath); } } } setState(() { files = list; folders = filteredFolders.isNotEmpty ? filteredFolders : foundFolders; loading = false; }); _detectRemoteMissingFiles(list); _checkForChanges(); // Check changes after fetch await _scanStorageFolders(); // refresh local folders from disk } catch (e) { setState(() { loading = false; error = 'HTTP fallback failed: $e'; }); } } // Sync settings persistence (simple JSON in HOME) Future _settingsFile() async { final home = Platform.environment['HOME'] ?? Directory.current.path; final f = File(p.join(home, '.planxo_sync_settings.json')); if (!await f.exists()) await f.create(recursive: true); return f; } Future _loadSyncSettings() async { try { final f = await _settingsFile(); final content = await f.readAsString(); if (content.trim().isEmpty) return; final Map data = jsonDecode(content); _syncSettings = data.map((k, v) => MapEntry(k, v)); // Load prefs final prefs = _syncSettings['_prefs']; if (prefs is Map && prefs['extract_to_root'] is bool) { _extractToRoot = prefs['extract_to_root'] as bool; } // start timers for enabled ones _syncSettings.forEach((folder, cfg) { if (cfg is Map && cfg['enabled'] == true && cfg['interval'] is int) { _startFolderPeriodicSync(folder, cfg['interval']); } }); } catch (e) { print('No sync settings or failed to load: $e'); } } Future _saveSyncSettings() async { try { // persist prefs inside settings file final prefs = (_syncSettings['_prefs'] is Map) ? Map.from(_syncSettings['_prefs']) : {}; prefs['extract_to_root'] = _extractToRoot; _syncSettings['_prefs'] = prefs; final f = await _settingsFile(); await f.writeAsString(jsonEncode(_syncSettings)); } catch (e) { print('Failed to save sync settings: $e'); } } Future _verifyStoragePath() async { final path = _storageBasePath.trim(); if (path.isEmpty) { _showSnack('Set a storage path first'); return; } try { final d = Directory(path); if (!await d.exists()) { await d.create(recursive: true); } final testFile = File(p.join(path, '.planxo_write_test')); await testFile.writeAsString('ok'); await testFile.delete(); _showSnack('Storage path verified and writable'); } catch (e) { _showSnack('Storage path not writable: $e'); } } void _startFolderPeriodicSync(String folder, int intervalMinutes) { // stop any existing _stopFolderPeriodicSync(folder); final timer = Timer.periodic(Duration(minutes: intervalMinutes), (_) async { if (_syncRunning[folder] == true) return; // skip if already running _syncRunning[folder] = true; try { await _downloadAndExtractFolder(folder); } catch (e) { print('Periodic sync failed for $folder: $e'); } finally { _syncRunning[folder] = false; } }); _syncTimers[folder] = timer; // update settings map _syncSettings[folder] = { 'enabled': true, 'interval': intervalMinutes, 'last_started': DateTime.now().toIso8601String() }; _saveSyncSettings(); } void _stopFolderPeriodicSync(String folder) { final t = _syncTimers.remove(folder); if (t != null && t.isActive) t.cancel(); if (_syncSettings.containsKey(folder)) { final cfg = _syncSettings[folder] as Map; cfg['enabled'] = false; _syncSettings[folder] = cfg; _saveSyncSettings(); } } Future _toggleSyncForFolder(String folder, bool enable) async { if (enable) { final interval = (_syncSettings[folder] != null && _syncSettings[folder]['interval'] is int) ? _syncSettings[folder]['interval'] as int : 15; _startFolderPeriodicSync(folder, interval); } else { _stopFolderPeriodicSync(folder); } setState(() {}); } Future _setIntervalForFolder(String folder, int minutes) async { final cfg = _syncSettings[folder] is Map ? Map.from(_syncSettings[folder]) : {}; cfg['interval'] = minutes; _syncSettings[folder] = cfg; _saveSyncSettings(); // restart timer if enabled if (cfg['enabled'] == true) { _startFolderPeriodicSync(folder, minutes); } setState(() {}); } Future _showIntervalDialog(String folder) async { final current = _syncSettings[folder] is Map && _syncSettings[folder]['interval'] is int ? _syncSettings[folder]['interval'] as int : 15; int selected = current; await showDialog(context: rootNavigatorKey.currentContext!, builder: (ctx) { return AlertDialog( title: Text('Sync interval for $folder'), content: Column( mainAxisSize: MainAxisSize.min, children: [ RadioListTile(value: 15, groupValue: selected, title: const Text('Every 15 minutes'), onChanged: (v) { if (v!=null) { selected = v; setState(() {}); } }), RadioListTile(value: 30, groupValue: selected, title: const Text('Every 30 minutes'), onChanged: (v) { if (v!=null) { selected = v; setState(() {}); } }), RadioListTile(value: 45, groupValue: selected, title: const Text('Every 45 minutes'), onChanged: (v) { if (v!=null) { selected = v; setState(() {}); } }), ], ), actions: [ TextButton(onPressed: () => Navigator.of(ctx).pop(), child: const Text('Cancel')), ElevatedButton(onPressed: () { _setIntervalForFolder(folder, selected); Navigator.of(ctx).pop(); }, child: const Text('Save')), ], ); }); } Future fetchPresignedUrl(String key) async { if (!_wsOpen) { _showSnack('WebSocket not connected'); return null; } // create completer and wait for presigned_url with matching key final completer = Completer(); _pendingPresign[key] = completer; final msg = jsonEncode({'type': 'presign', 'data': {'key': key}}); _ws!.add(msg); try { final url = await completer.future.timeout(const Duration(seconds: 10)); return url; } catch (e) { _pendingPresign.remove(key); _showSnack('Failed to get presigned URL: $e'); return null; } } Future _downloadAndExtractFolder(String folder) async { try { if (_folderDownloading[folder] == true) return 0; _folderDownloading[folder] = true; await _unlockStorage(); // lift immutable flag before writing int updates = 0; await _logEvent(eventType: 'download_folder_start', folder: folder, status: 'started'); _showSnack('Checking folder $folder...'); final envUrl = const String.fromEnvironment('ASSET_MANAGER_URL'); final baseUrl = envUrl.isNotEmpty ? envUrl : assetManagerUrl; final url = '$baseUrl/folders/download?folder=${Uri.encodeComponent(folder)}'; final response = await dio.get>( url, options: Options(responseType: ResponseType.bytes, followRedirects: true), ); final bytes = response.data; if (bytes == null) { _showSnack('Folder download returned empty'); return 0; } final base = _storageBasePath.isNotEmpty ? _storageBasePath : (Platform.environment['HOME'] ?? Directory.current.path); // Ensure base storage path exists final baseDir = Directory(base); if (!baseDir.existsSync()) { try { baseDir.createSync(recursive: true); print('Created base storage directory: ${baseDir.path}'); } catch (e) { _showSnack('Failed to create base storage path: $e'); await _logEvent(eventType: 'download_folder_error', folder: folder, status: 'error', message: 'Failed to create base directory: $e'); return 0; } } Directory projectDir; if (_extractToRoot) { var normalized = folder; if (normalized.startsWith('/')) normalized = normalized.substring(1); if (normalized.endsWith('/')) normalized = normalized.substring(0, normalized.length - 1); projectDir = Directory(p.join(base, normalized)); } else { projectDir = Directory(p.join(base, 'ProjectsCache', folder.replaceAll('/', '_'))); } // Ensure all parent directories exist (e.g., base/clients/691481da936c80306db4cab9/691b07252227750fbe8703ee/) if (!projectDir.existsSync()) { try { projectDir.createSync(recursive: true); print('Created project directory: ${projectDir.path}'); } catch (e) { _showSnack('Failed to create project folder: $e'); await _logEvent(eventType: 'download_folder_error', folder: folder, status: 'error', message: 'Failed to create project directory: $e'); return 0; } } final archive = ZipDecoder().decodeBytes(bytes); for (final file in archive) { // ── Zip Slip protection ────────────────────────────────────────────── // Normalise the entry name, reject any path that escapes the project dir. final safeName = p.normalize(file.name).replaceAll('\\', '/'); if (safeName.startsWith('..') || safeName.contains('/../')) { print('Skipping unsafe zip entry: ${file.name}'); continue; } final outPath = p.join(projectDir.path, safeName); // Double-check: resolved path must still be inside projectDir if (!p.isWithin(projectDir.path, outPath) && outPath != projectDir.path) { print('Zip Slip blocked: $outPath is outside ${projectDir.path}'); continue; } if (file.isFile) { final outFile = File(outPath); final parentDir = outFile.parent; parentDir.createSync(recursive: true); // Only overwrite if remote is newer or file doesn't exist bool shouldWrite = true; if (await outFile.exists()) { final localStat = await outFile.stat(); final remoteMtime = DateTime.fromMillisecondsSinceEpoch(file.lastModTime * 1000); if (localStat.modified.isAfter(remoteMtime)) { shouldWrite = false; } } if (shouldWrite) { outFile.writeAsBytesSync(file.content as List); // Update sync meta _syncMeta[outFile.path] = { 'lastModified': (await outFile.stat()).modified.millisecondsSinceEpoch, 'hash': await _hashFile(outFile.path), }; updates++; } } else { Directory(outPath).createSync(recursive: true); } } _currentProjectDir = projectDir; _currentFolderPrefix = folder.endsWith('/') ? folder : '$folder/'; await _primeKnownKeys(_currentFolderPrefix!); if (updates > 0) { _showSnack('Updated $updates files in ${projectDir.path}'); } await _logEvent(eventType: 'download_folder_extracted', folder: folder, localPath: projectDir.path, status: 'ok'); _startWatcher(projectDir, _currentFolderPrefix!); _lastFolderDownloadTs[folder] = DateTime.now(); // Track for easy resume _lastSyncedFolder = _currentFolderPrefix; _lastSyncedLocalPath = projectDir.path; _checkForChanges(); // Refresh changes list return updates; } on DioException catch (e) { if (e.type == DioExceptionType.connectionError) { _showSnack('Folder download failed: Connection error. Please check the server.'); } else { _showSnack('Folder download failed: ${e.message}'); } await _logEvent(eventType: 'download_folder_error', folder: folder, status: 'error', message: e.message); return 0; } catch (e) { _showSnack('Folder download failed: $e'); await _logEvent(eventType: 'download_folder_error', folder: folder, status: 'error', message: '$e'); return 0; } finally { _folderDownloading[folder] = false; await _lockStorage(); // re-apply hidden + immutable flag } } /// Trigger a full sync for all known folders. /// This will sequentially download and extract each folder. Future syncFiles() async { if (folders.isEmpty) { _showSnack('No folders to sync'); return; } int totalUpdates = 0; for (final folder in folders) { try { totalUpdates += await _downloadAndExtractFolder(folder); } catch (e) { print('syncFiles: error syncing $folder: $e'); _showSnack('Sync failed for $folder: $e'); } } if (totalUpdates == 0) { _showSnack('All folders up to date.'); } else { _showSnack('Sync completed. Updated $totalUpdates files.'); } } void startFileWatcher(Directory directory, String folderPrefix) { final watcher = DirectoryWatcher(directory.path); watcher.events.listen((event) async { if (event.type == ChangeType.ADD || event.type == ChangeType.MODIFY) { final file = File(event.path); if (await file.exists()) { await syncFile(file, folderPrefix, _currentProjectDir!); } } }); } Future syncFile(File file, String folderPrefix, Directory directory) async { final now = DateTime.now(); final localPath = file.path; // Debounce rapid consecutive writes final last = _lastUpload[localPath]; if (last != null && now.difference(last) < const Duration(seconds: 1)) return; _lastUpload[localPath] = now; // Compute file hash final bytes = await file.readAsBytes(); final hash = crypto.sha256.convert(bytes).toString(); // Skip upload if content hash is unchanged if (_lastUploadedHash[localPath] == hash) return; // Determine relative path for S3 final relativePath = p.relative(file.path, from: directory.path).replaceAll('\\', '/'); final s3Key = (folderPrefix.endsWith('/') ? folderPrefix : '$folderPrefix/') + relativePath; // Get last modified and hash final stat = await file.stat(); final lastModified = stat.modified.millisecondsSinceEpoch; final currHash = await _hashFile(localPath); // Check sync meta to avoid redundant upload final meta = _syncMeta[localPath]; if (meta != null && meta['lastModified'] == lastModified && meta['hash'] == currHash) { // No change since last sync return; } // Upload file try { final contentType = guessContentType(file.path); if (_lastUploadedHash.containsKey(file.path)) { await _replaceExisting(file.path, s3Key, contentType); } else { // Always use relativePath (with subfolders) for upload await _uploadNew(file.path, folderPrefix, relativePath, contentType); } _lastUploadedHash[localPath] = hash; // Update hash after successful upload // Update sync meta _syncMeta[localPath] = { 'lastModified': lastModified, 'hash': currHash, }; _saveSyncMeta(); // Persist } catch (e) { print('Error syncing file $localPath: $e'); } } Future _primeKnownKeys(String folderPrefix) async { try { final envUrl = const String.fromEnvironment('ASSET_MANAGER_URL'); final baseUrl = envUrl.isNotEmpty ? envUrl : assetManagerUrl; final url = '$baseUrl/assets?folder=${Uri.encodeComponent(folderPrefix)}'; final res = await dio.get(url); _knownS3Keys.clear(); if (res.statusCode == 200 && res.data is List) { for (final a in (res.data as List)) { final key = a['s3_key']; if (key is String) _knownS3Keys.add(key); } } } catch (e) { // non-fatal print('primeKnownKeys error: $e'); } } void _startWatcher(Directory dir, String folderPrefix) { // Cancel existing watcher for this folder if any, then register a new one. // Using a Map so we can watch multiple folders simultaneously. _watchSubs[folderPrefix]?.cancel(); final sub = DirectoryWatcher(dir.path).events.listen((event) { if (event.type == ChangeType.ADD || event.type == ChangeType.MODIFY) { _onLocalFileChanged(event.path, folderPrefix); } else if (event.type == ChangeType.REMOVE) { _onLocalFileDeleted(event.path, folderPrefix); } }); _watchSubs[folderPrefix] = sub; // Also keep legacy _watchSub pointing to the most recent one for dispose() _watchSub = sub; } void _stopAllWatchers() { for (final sub in _watchSubs.values) sub.cancel(); _watchSubs.clear(); _watchSub = null; } Future _onLocalFileDeleted(String localPath, String folderPrefix) async { final baseName = p.basename(localPath); if (baseName == '.DS_Store' || baseName == '.keep' || baseName.startsWith('~') || baseName.endsWith('~')) return; if (_currentProjectDir == null) return; final relative = p.relative(localPath, from: _currentProjectDir!.path).replaceAll('\\', '/'); final s3Key = (folderPrefix.endsWith('/') ? folderPrefix : '$folderPrefix/') + relative; try { final envUrl = const String.fromEnvironment('ASSET_MANAGER_URL'); final baseUrl = envUrl.isNotEmpty ? envUrl : assetManagerUrl; await dio.delete('$baseUrl/assets/delete-by-key', queryParameters: {'key': s3Key}); _knownS3Keys.remove(s3Key); _syncMeta.remove(localPath); _saveSyncMeta(); _showSnack('Deleted $baseName from S3'); await _logEvent(eventType: 'delete_local', folder: folderPrefix, s3Key: s3Key, localPath: localPath, status: 'ok'); _checkForChanges(); } catch (e) { print('Delete sync error for $s3Key: $e'); await _logEvent(eventType: 'delete_local', folder: folderPrefix, s3Key: s3Key, localPath: localPath, status: 'error', message: '$e'); } } Future _onLocalFileChanged(String localPath, String folderPrefix) async { // Skip non-existent files (already cleaned up by OS) if (!File(localPath).existsSync()) return; final baseName = p.basename(localPath); // ── Skip system files ──────────────────────────────────────────────────── if (baseName == '.DS_Store' || baseName == '.keep') return; // ── Skip temp / backup files created by Illustrator, Photoshop & others ─ // Illustrator writes ~filename.ai and filename.ai~ during save. // Photoshop writes ~PST*.tmp and similar intermediate files. // Office apps write ~$filename.docx lock/temp files. if (baseName.startsWith('~') || // any leading-tilde temp file baseName.endsWith('~') || // trailing-tilde backup copy baseName.endsWith('.tmp') || // generic temp extension baseName.endsWith('.lock') || // lock files baseName.endsWith('.lck') || // alternative lock extension baseName == 'Thumbs.db') return; // Windows thumbnail cache // ── Stable-file debounce ───────────────────────────────────────────────── // Reset the timer every time the watcher fires for this path. // The upload only happens after the file has been UNTOUCHED for 5 seconds, // which gives Illustrator/Photoshop time to finish their multi-step save. _uploadDebounceTimers[localPath]?.cancel(); // 5 s stable-file window: enough for Illustrator/Photoshop multi-step saves _uploadDebounceTimers[localPath] = Timer(const Duration(seconds: 5), () { _uploadDebounceTimers.remove(localPath); _performFileUpload(localPath, folderPrefix); }); } Future _performFileUpload(String localPath, String folderPrefix) async { final file = File(localPath); if (!file.existsSync()) return; if (_currentProjectDir == null) return; // Use relative path from project dir, preserving all subfolders final relative = p.relative(localPath, from: _currentProjectDir!.path).replaceAll('\\', '/'); // S3 key must preserve folder structure: folderPrefix + relative final s3Key = (folderPrefix.endsWith('/') ? folderPrefix : '$folderPrefix/') + relative; final contentType = guessContentType(localPath); // Get last modified and hash final stat = await file.stat(); final lastModified = stat.modified.millisecondsSinceEpoch; final currHash = await _hashFile(localPath); // Check sync meta to avoid redundant upload final meta = _syncMeta[localPath]; if (meta != null && meta['lastModified'] == lastModified && meta['hash'] == currHash) { // No change since last sync return; } try { if (_knownS3Keys.contains(s3Key)) { await _replaceExisting(localPath, s3Key, contentType); } else { // Always use relative (with subfolders) for upload await _uploadNew(localPath, folderPrefix, relative, contentType); _knownS3Keys.add(s3Key); } _lastUploadedHash[localPath] = currHash; // Update sync meta _syncMeta[localPath] = { 'lastModified': lastModified, 'hash': currHash, }; _saveSyncMeta(); // Persist _checkForChanges(); // Refresh changes list } catch (e) { print('Upload error for $localPath: $e'); try { await _logEvent(eventType: 'upload_error', folder: folderPrefix, s3Key: s3Key, localPath: localPath, status: 'error', message: '$e'); } catch (_) {} } } Future _hashFile(String path) async { final f = File(path); final bytes = await f.readAsBytes(); final digest = crypto.sha256.convert(bytes); return digest.toString(); } Future _logEvent({ required String eventType, String? folder, String? s3Key, String? localPath, int? size, String? hash, String? status, String? message, }) async { try { await _db.insertEvent( eventType: eventType, folder: folder, s3Key: s3Key, localPath: localPath, size: size, hash: hash, status: status, message: message, ); } catch (_) {} } Future _refreshEvents() async { setState(() { _logLoading = true; }); try { var rows = await _db.recentEvents(limit: 200); final filterPrefix = _logFilterCurrentOnly ? _currentFolderPrefix : null; if (filterPrefix != null) { rows = rows.where((e) => (e['folder'] as String?) == filterPrefix).toList(); } setState(() { _recentEvents = rows; }); } catch (e) { _showSnack('Failed to load sync log: $e'); } finally { if (mounted) { setState(() { _logLoading = false; }); } } } String guessContentType(String path) { final ext = p.extension(path).toLowerCase(); switch (ext) { case '.txt': return 'text/plain'; case '.json': return 'application/json'; case '.png': return 'image/png'; case '.jpg': case '.jpeg': return 'image/jpeg'; case '.pdf': return 'application/pdf'; default: return 'application/octet-stream'; } } Future _replaceExisting(String localPath, String s3Key, String contentType) async { final envUrl = const String.fromEnvironment('ASSET_MANAGER_URL'); final baseUrl = envUrl.isNotEmpty ? envUrl : assetManagerUrl; final presignRes = await dio.post('$baseUrl/assets/presign-replace', data: { 's3_key': s3Key, 'owner_id': uniqueId, 'content_type': contentType, }); final uploadUrl = presignRes.data['upload_url']; final assetId = presignRes.data['asset_id']; final file = File(localPath); final bytes = await file.readAsBytes(); // Perform upload and capture full response for diagnostics (don't throw immediately on 500) final putRes = await dio.put( uploadUrl, data: Stream.fromIterable([bytes]), options: Options( headers: { 'Content-Type': contentType, 'Content-Length': bytes.length.toString(), }, validateStatus: (status) => true, ), ); if (putRes.statusCode == null || putRes.statusCode! >= 400) { print('Upload (replace) failed. status=${putRes.statusCode} statusMessage=${putRes.statusMessage}'); print('Upload (replace) response data: ${putRes.data}'); _showSnack('Upload failed (status ${putRes.statusCode}). See logs for details.'); throw DioException(requestOptions: RequestOptions(path: uploadUrl), response: putRes); } await dio.post('$baseUrl/assets/complete-replace', data: { 'asset_id': assetId, 'size': bytes.length, 'owner_id': uniqueId, }); _showSnack('Updated $s3Key'); // Update files list locally to reflect changes immediately setState(() { final idx = files.indexWhere((f) => f.key == s3Key); if (idx != -1) { files[idx] = FileItem( key: s3Key, name: files[idx].name, size: bytes.length, ); } }); try { await _logEvent(eventType: 'upload_replace', folder: _currentFolderPrefix, s3Key: s3Key, localPath: localPath, size: bytes.length, status: 'ok'); } catch (_) {} // Debounce refresh: batch rapid uploads into a single refresh 3 s after the last one _refreshDebounceTimer?.cancel(); _refreshDebounceTimer = Timer(const Duration(seconds: 3), _fetchFileListHttpFallback); } Future _uploadNew(String localPath, String folderPrefix, String relativePath, String contentType) async { // baseUrl resolved from env or fallback constant final envUrl = const String.fromEnvironment('ASSET_MANAGER_URL'); final baseUrl = envUrl.isNotEmpty ? envUrl : assetManagerUrl; final normalizedFolderPrefix = folderPrefix.endsWith('/') ? folderPrefix : '$folderPrefix/'; // preserve folder structure separators final normalizedRelativePath = relativePath.replaceAll('\\', '/'); // final S3 key we intend final s3Key = '$normalizedFolderPrefix$normalizedRelativePath'; // determine directory part of relativePath (may be "." for root) final String fileDir = p.dirname(normalizedRelativePath); final String folderForPresign = (fileDir == '.' || fileDir.isEmpty) ? normalizedFolderPrefix : (normalizedFolderPrefix + (fileDir.endsWith('/') ? fileDir : '$fileDir/')); // presign payload: folder contains subfolder, filename is basename, still provide explicit s3_key final presignPayload = { 'folder': folderForPresign, 'filename': p.basename(normalizedRelativePath), 's3_key': s3Key, 'content_type': contentType, 'owner_id': uniqueId, }; // debug: log what we are about to send print('presignPayload: $presignPayload'); Response presignRes; try { presignRes = await dio.post( '$baseUrl/assets/presign-upload', data: presignPayload, ); } catch (e) { throw Exception('Presign request failed: $e'); } print('presign response: ${presignRes.data}'); // Use server-returned s3_key (server may sanitize filename, e.g. spaces → underscores) String effectiveS3Key = s3Key; String effectiveFilename = p.basename(normalizedRelativePath); try { final returnedKey = presignRes.data is Map ? presignRes.data['s3_key'] as String? : null; if (returnedKey != null && returnedKey.isNotEmpty) { if (returnedKey != s3Key) { print('Note: server sanitized s3_key: $returnedKey (requested $s3Key) — using server key'); } effectiveS3Key = returnedKey; effectiveFilename = effectiveS3Key.split('/').last; } } catch (_) {} // tolerant extraction of upload URL (some APIs use 'upload_url' or 'url' or 'put_url') final uploadUrl = presignRes.data is Map ? (presignRes.data['upload_url'] ?? presignRes.data['url'] ?? presignRes.data['put_url']) : null; if (uploadUrl == null || uploadUrl.toString().isEmpty) { throw Exception('Presign response missing upload URL: ${presignRes.data}'); } final file = File(localPath); if (!await file.exists()) throw Exception('File not found: $localPath'); final bytes = await file.readAsBytes(); Response putRes; try { putRes = await dio.put( uploadUrl, data: bytes, options: Options( headers: {'Content-Type': contentType}, ), ); } catch (e) { throw Exception('Upload to S3 failed: $e'); } final status = putRes.statusCode ?? 0; if (status >= 400) { throw Exception('Failed to upload new file to S3 (status $status)'); } // Confirm upload so MongoDB record moves from status:uploading → active try { final presignAssetId = presignRes.data is Map ? presignRes.data['asset_id'] as String? : null; if (presignAssetId != null) { await dio.post('$baseUrl/assets/complete-upload', data: { 'asset_id': presignAssetId, 'size': bytes.length, }); } } catch (e) { print('Warning: failed to confirm upload on backend: $e'); } // Update local metadata using the effective (server-assigned) key try { _knownS3Keys.add(effectiveS3Key); _lastUploadedHash[localPath] = crypto.sha256.convert(bytes).toString(); final stat = await file.stat(); final lastModified = stat.modified.millisecondsSinceEpoch; _syncMeta[localPath] = { 'lastModified': lastModified, 'hash': _lastUploadedHash[localPath], }; // Add to files list so UI updates immediately setState(() { final newItem = FileItem( key: effectiveS3Key, name: effectiveFilename, size: bytes.length, ); files.removeWhere((f) => f.key == effectiveS3Key || f.key == s3Key); files.add(newItem); }); await _logEvent(eventType: 'upload_new', folder: folderForPresign, s3Key: effectiveS3Key, localPath: localPath, status: 'ok'); // Debounce refresh: batch rapid uploads into a single refresh 3 s after the last one _refreshDebounceTimer?.cancel(); _refreshDebounceTimer = Timer(const Duration(seconds: 3), _fetchFileListHttpFallback); } catch (e) { print('Warning: failed to update local sync metadata: $e'); } print('Uploaded $localPath → $s3Key'); } Future downloadFile(FileItem item) async { final presigned = await fetchPresignedUrl(item.key); if (presigned == null) return null; await _unlockStorage(); // lift immutable flag before writing final base = _storageBasePath.isNotEmpty ? _storageBasePath : (Platform.environment['HOME'] ?? ''); final downloadsDir = Directory(p.join(base, 'Downloads')); if (!await downloadsDir.exists()) { await downloadsDir.create(recursive: true); } final targetPath = p.join(downloadsDir.path, item.name); final tempPath = '$targetPath.part'; final token = CancelToken(); item.cancelToken = token; item.isDownloading = true; item.received = 0; item.total = 0; void updateState() => setState(() {}); try { await dio.download( presigned, tempPath, cancelToken: token, options: Options(responseType: ResponseType.stream, followRedirects: true), onReceiveProgress: (received, total) { item.received = received; item.total = total; updateState(); // report progress to server via WebSocket (non-blocking) if (_wsOpen) { try { _ws!.add(jsonEncode({ 'type': 'download_progress', 'data': {'key': item.key, 'received': received, 'total': total} })); } catch (e) { // ignore WS send errors (non-fatal) } } }, ); final tmp = File(tempPath); final finalFile = File(targetPath); if (await finalFile.exists()) { // overwrite await finalFile.delete(); } await tmp.rename(finalFile.path); item.localPath = finalFile.path; _showSnack('Saved ${item.name} → ${finalFile.path}'); // notify server of completion if (_wsOpen) { try { _ws!.add(jsonEncode({ 'type': 'download_complete', 'data': {'key': item.key, 'path': item.localPath} })); } catch (_) {} } return finalFile; } on DioException catch (e) { if (CancelToken.isCancel(e)) { _showSnack('Download cancelled: ${item.name}'); } else { _showSnack('Download failed: ${item.name} (${e.message})'); } return null; } catch (e) { _showSnack('Download error: $e'); return null; } finally { item.isDownloading = false; item.cancelToken = null; item.received = 0; item.total = 0; updateState(); await _lockStorage(); // re-apply hidden + immutable flag } } void cancelDownload(FileItem item) { if (item.cancelToken != null && !item.cancelToken!.isCancelled) { item.cancelToken!.cancel('user cancelled'); } } void openInFinder(String path) { // macOS: open containing folder in Finder Process.run('open', ['-R', path]); } void _showSnack(String message) { // Prefer global messenger key to avoid early-lifecycle issues final messenger = rootMessengerKey.currentState; if (messenger != null) { messenger.showSnackBar(SnackBar(content: Text(message))); return; } // Fallback to context-based messenger when available if (mounted) { ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message))); } else { // Schedule after first frame if neither is ready WidgetsBinding.instance.addPostFrameCallback((_) { final m2 = rootMessengerKey.currentState; m2?.showSnackBar(SnackBar(content: Text(message))); }); } } String prettyBytes(int bytes) { if (bytes < 1024) return '$bytes B'; if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; } // ─── New 3-column Dropinside-style layout ─────────────────────────────────── /// Sidebar selection: null = All Files overview, non-null = selected folder String? _selectedSidebarFolder; /// Sub-folder navigation stack within the selected sidebar folder. final List _folderNavStack = []; /// Search text in center pane String _searchQuery = ''; /// Selected file for the right-panel file-details view FileItem? _selectedFile; /// Whether the right panel is showing file details (true) or sync status (false) bool _showFileDetails = false; @override Widget build(BuildContext context) { return MaterialApp( debugShowCheckedModeBanner: false, title: 'Plan XO', scaffoldMessengerKey: rootMessengerKey, navigatorKey: rootNavigatorKey, theme: ThemeData( colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFFE2601A)), useMaterial3: true, fontFamily: 'Inter', ), home: loading ? const Scaffold(body: Center(child: CircularProgressIndicator())) : !_isAuthenticated ? _buildLoginScreen() : _buildMainShell(), ); } // ── Main shell: top bar + 3 columns ───────────────────────────────────────── Widget _buildMainShell() { return Scaffold( backgroundColor: const Color(0xFFF0F2F5), body: Column( children: [ _buildTopBar(), Expanded( child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildLeftSidebar(), Expanded(child: _buildCenterPane()), _buildRightPanel(), ], ), ), ], ), ); } // ── Brand colour ───────────────────────────────────────────────────────────── static const _coral = Color(0xFFE2601A); // ── Top bar ────────────────────────────────────────────────────────────────── Widget _buildTopBar() { final pending = _changes.where((c) => c.status != 'synced').length; final username = _userInfo != null ? ((_userInfo!['username'] as String?) ?? 'U') : (_clientName.isNotEmpty ? _clientName : 'U'); final initial = username.substring(0, 1).toUpperCase(); return Container( height: 52, decoration: const BoxDecoration( color: Colors.white, border: Border(bottom: BorderSide(color: Color(0xFFEEEEEE))), ), padding: const EdgeInsets.symmetric(horizontal: 20), child: Row( children: [ Container( width: 30, height: 30, decoration: BoxDecoration(color: _coral, borderRadius: BorderRadius.circular(8)), child: const Icon(Icons.folder_rounded, color: Colors.white, size: 17), ), const SizedBox(width: 10), const Text('PlanXO', style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold, color: Color(0xFF1A1A2E))), const Spacer(), // WS status pill Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), decoration: BoxDecoration( color: _wsOpen ? const Color(0xFFEAF6EC) : const Color(0xFFFCECEC), borderRadius: BorderRadius.circular(20), ), child: Row(mainAxisSize: MainAxisSize.min, children: [ _buildWsIndicator(), const SizedBox(width: 5), Text( _wsOpen ? 'Online' : (_wsConnecting ? 'Connecting…' : 'Offline'), style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, color: _wsOpen ? Colors.green.shade700 : Colors.red.shade700, ), ), ]), ), if (pending > 0) ...[ const SizedBox(width: 8), Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), decoration: BoxDecoration( color: const Color(0xFFFFF3EC), borderRadius: BorderRadius.circular(20), border: Border.all(color: _coral.withValues(alpha: 0.3)), ), child: Row(mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.sync_outlined, size: 12, color: _coral), const SizedBox(width: 4), Text('$pending pending', style: const TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: _coral)), ]), ), ], const SizedBox(width: 12), PopupMenuButton( tooltip: '', onSelected: (val) { if (val == 'logout') { WidgetsBinding.instance.addPostFrameCallback((_) async { await _clearAuthKey(); _showSnack('Logged out successfully'); }); } else if (val == 'storage') { _showStorageDialog(); } }, itemBuilder: (_) => [ const PopupMenuItem(value: 'storage', child: Row(children: [Icon(Icons.folder_outlined, size: 16), SizedBox(width: 8), Text('Storage settings')])), const PopupMenuDivider(), const PopupMenuItem(value: 'logout', child: Row(children: [Icon(Icons.logout_rounded, size: 16), SizedBox(width: 8), Text('Logout')])), ], child: Row(mainAxisSize: MainAxisSize.min, children: [ CircleAvatar(radius: 15, backgroundColor: _coral, child: Text(initial, style: const TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.bold))), const SizedBox(width: 4), const Icon(Icons.keyboard_arrow_down_rounded, size: 16, color: Color(0xFF888888)), ]), ), ], ), ); } Widget _topBarTab(String label, bool active) { return InkWell( onTap: () {}, borderRadius: BorderRadius.circular(6), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Text(label, style: TextStyle( fontSize: 14, fontWeight: active ? FontWeight.w600 : FontWeight.normal, color: active ? const Color(0xFF1A1A2E) : const Color(0xFF888888), )), const SizedBox(height: 2), if (active) Container( height: 2, width: 36, decoration: BoxDecoration( color: const Color(0xFF1A73E8), borderRadius: BorderRadius.circular(2), ), ), ], ), ), ); } // ── Left Sidebar ───────────────────────────────────────────────────────────── Widget _buildLeftSidebar() { final recent = files.take(6).toList(); return Container( width: 248, decoration: const BoxDecoration( color: Colors.white, border: Border(right: BorderSide(color: Color(0xFFEEEEEE))), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Header Padding( padding: const EdgeInsets.fromLTRB(16, 16, 12, 10), child: Row(children: [ const Text('Files', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Color(0xFF1A1A2E))), const Spacer(), IconButton(icon: const Icon(Icons.open_in_full_rounded, size: 14, color: Color(0xFFAAAAAA)), onPressed: _showStorageDialog, visualDensity: VisualDensity.compact, tooltip: 'Storage'), ]), ), // Search bar Padding( padding: const EdgeInsets.fromLTRB(12, 0, 12, 12), child: Container( height: 34, decoration: BoxDecoration(color: const Color(0xFFF3F4F6), borderRadius: BorderRadius.circular(10)), child: TextField( onChanged: (v) => setState(() => _searchQuery = v.toLowerCase()), style: const TextStyle(fontSize: 13), decoration: const InputDecoration( hintText: 'Search…', hintStyle: TextStyle(fontSize: 13, color: Color(0xFFAAAAAA)), prefixIcon: Icon(Icons.search, size: 16, color: Color(0xFFAAAAAA)), border: InputBorder.none, contentPadding: EdgeInsets.symmetric(vertical: 8), ), ), ), ), // CATEGORIES label Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 6), child: Text('CATEGORIES', style: TextStyle(fontSize: 10, fontWeight: FontWeight.w700, color: Colors.grey.shade500, letterSpacing: 0.8)), ), // All Files _sidebarCategoryRow(icon: Icons.grid_view_rounded, label: 'All Files', count: folders.length + localFolders.length, selected: _selectedSidebarFolder == null, onTap: () => setState(() { _selectedSidebarFolder = null; _folderNavStack.clear(); }), active: true), // Remote folders for (final folder in folders) _sidebarCategoryRow(icon: Icons.folder_rounded, label: folder.replaceAll(RegExp(r'/$'), '').split('/').last, count: null, selected: _selectedSidebarFolder == folder, onTap: () => setState(() { _selectedSidebarFolder = folder; _folderNavStack.clear(); }), active: false), // Local folders for (final full in localFolders) _sidebarCategoryRow(icon: Icons.folder_open_rounded, label: p.basename(full), count: null, selected: _selectedSidebarFolder == full, onTap: () => setState(() { _selectedSidebarFolder = full; _folderNavStack.clear(); }), active: false, isLocal: true), const SizedBox(height: 8), const Padding(padding: EdgeInsets.symmetric(horizontal: 16), child: Divider(height: 1, color: Color(0xFFF0F0F0))), const SizedBox(height: 8), // LATEST FILES label Padding( padding: const EdgeInsets.fromLTRB(16, 0, 12, 8), child: Row(children: [ Text('LATEST FILES', style: TextStyle(fontSize: 10, fontWeight: FontWeight.w700, color: Colors.grey.shade500, letterSpacing: 0.8)), const Spacer(), GestureDetector(onTap: fetchFileList, child: const Icon(Icons.add, size: 16, color: Color(0xFFAAAAAA))), ]), ), // Recent files list Expanded( child: SingleChildScrollView( child: Column(children: [ if (recent.isEmpty) const Padding(padding: EdgeInsets.all(16), child: Text('No files yet', style: TextStyle(fontSize: 12, color: Color(0xFFAAAAAA)))) else for (final file in recent) _sidebarFileRow(file), ]), ), ), // Footer: user info if (_userInfo != null) Container( padding: const EdgeInsets.fromLTRB(16, 10, 16, 14), decoration: const BoxDecoration(border: Border(top: BorderSide(color: Color(0xFFF0F0F0)))), child: Row(children: [ CircleAvatar(radius: 13, backgroundColor: _coral, child: Text( ((_userInfo!['username'] as String?) ?? 'U').substring(0, 1).toUpperCase(), style: const TextStyle(color: Colors.white, fontSize: 11, fontWeight: FontWeight.bold), )), const SizedBox(width: 8), Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Text((_userInfo!['username'] as String?) ?? '', style: const TextStyle(fontSize: 11, fontWeight: FontWeight.w600), overflow: TextOverflow.ellipsis), Text((_userInfo!['role'] as String?) ?? '', style: const TextStyle(fontSize: 10, color: Color(0xFFAAAAAA))), ])), ]), ), ], ), ); } Widget _sidebarCategoryRow({required IconData icon, required String label, required int? count, required bool selected, required VoidCallback onTap, required bool active, bool isLocal = false}) { final isSyncing = isLocal ? localFolders.any((f) => p.basename(f) == label && _currentProjectDir?.path == f) : (_currentFolderPrefix != null && _currentFolderPrefix!.contains(label)); return GestureDetector( onTap: onTap, child: Container( margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 1), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), decoration: BoxDecoration(color: selected ? _coral : Colors.transparent, borderRadius: BorderRadius.circular(10)), child: Row(children: [ Container( width: 26, height: 26, decoration: BoxDecoration(color: selected ? Colors.white.withValues(alpha: 0.25) : const Color(0xFFF0F0F0), borderRadius: BorderRadius.circular(7)), child: Icon(icon, size: 14, color: selected ? Colors.white : (isLocal ? const Color(0xFFFBBC04) : const Color(0xFF888888))), ), const SizedBox(width: 10), Expanded(child: Text(label, style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: selected ? Colors.white : const Color(0xFF444444)), overflow: TextOverflow.ellipsis)), if (isSyncing) Container(width: 6, height: 6, decoration: const BoxDecoration(color: Colors.green, shape: BoxShape.circle)), if (count != null && count > 0) ...[ const SizedBox(width: 4), Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration(color: selected ? Colors.white.withValues(alpha: 0.25) : const Color(0xFFE5E7EB), borderRadius: BorderRadius.circular(10)), child: Text('$count', style: TextStyle(fontSize: 10, fontWeight: FontWeight.w600, color: selected ? Colors.white : const Color(0xFF666666))), ), ] else Icon(active ? Icons.keyboard_arrow_down : Icons.chevron_right, size: 14, color: selected ? Colors.white.withValues(alpha: 0.7) : const Color(0xFFCCCCCC)), ]), ), ); } Widget _sidebarFileRow(FileItem file) { final ext = file.name.contains('.') ? file.name.split('.').last.toLowerCase() : ''; final sizeStr = file.size != null ? (file.size! > 1024 * 1024 ? '${(file.size! / (1024 * 1024)).toStringAsFixed(1)} MB' : '${(file.size! / 1024).toStringAsFixed(1)} KB') : '—'; final isSelected = _selectedFile?.key == file.key; return GestureDetector( onTap: () => setState(() { _selectedFile = file; _showFileDetails = true; }), child: Container( margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 1), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7), decoration: BoxDecoration(color: isSelected ? const Color(0xFFFFF3EC) : Colors.transparent, borderRadius: BorderRadius.circular(10)), child: Row(children: [ Container(width: 32, height: 32, decoration: BoxDecoration(color: _fileExtColor(ext).withValues(alpha: 0.12), borderRadius: BorderRadius.circular(8)), child: Icon(_fileIcon(ext), size: 16, color: _fileExtColor(ext))), const SizedBox(width: 8), Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(file.name, style: const TextStyle(fontSize: 11, fontWeight: FontWeight.w500, color: Color(0xFF333333)), overflow: TextOverflow.ellipsis), Text(sizeStr, style: const TextStyle(fontSize: 10, color: Color(0xFFAAAAAA))), ])), const Icon(Icons.more_horiz, size: 14, color: Color(0xFFCCCCCC)), ]), ), ); } Color _fileExtColor(String ext) { switch (ext) { case 'pdf': return const Color(0xFFD85A30); case 'ai': case 'eps': return const Color(0xFF3B6D11); case 'psd': case 'png': case 'jpg': case 'jpeg': return const Color(0xFF185FA5); case 'xls': case 'xlsx': case 'csv': return const Color(0xFF0F6E56); case 'doc': case 'docx': return const Color(0xFF2563EB); default: return const Color(0xFF888888); } } Widget _sidebarSectionHeader(String label, String count) { return Padding( padding: const EdgeInsets.fromLTRB(12, 8, 12, 2), child: Row( children: [ Text(label.toUpperCase(), style: const TextStyle(fontSize: 10, fontWeight: FontWeight.w600, color: Color(0xFFAAAAAA), letterSpacing: 0.5)), const SizedBox(width: 4), Text(count, style: const TextStyle(fontSize: 10, color: Color(0xFFAAAAAA))), ], ), ); } Widget _sidebarItem({required IconData icon, required Color iconColor, required String label, required bool selected, required VoidCallback onTap}) { return InkWell( onTap: onTap, child: Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7), decoration: BoxDecoration( color: selected ? const Color(0xFFE8F0FE) : Colors.transparent, borderRadius: BorderRadius.circular(6), ), margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), child: Row( children: [ Icon(icon, size: 16, color: selected ? const Color(0xFF1A73E8) : iconColor), const SizedBox(width: 8), Expanded(child: Text(label, style: TextStyle(fontSize: 13, color: selected ? const Color(0xFF1A73E8) : const Color(0xFF333333), fontWeight: selected ? FontWeight.w600 : FontWeight.normal), overflow: TextOverflow.ellipsis)), ], ), ), ); } Widget _sidebarFolderTile(String folderPath, {required bool isLocal}) { final displayName = isLocal ? p.basename(folderPath) : folderPath.replaceAll(RegExp(r'/$'), '').split('/').last; final fullLabel = isLocal ? folderPath : folderPath; final selected = _selectedSidebarFolder == fullLabel; final isSyncing = _currentFolderPrefix == folderPath || (isLocal && _currentProjectDir?.path == folderPath); return InkWell( onTap: () => setState(() { _selectedSidebarFolder = fullLabel; _folderNavStack.clear(); }), child: Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7), decoration: BoxDecoration( color: selected ? const Color(0xFFE8F0FE) : Colors.transparent, borderRadius: BorderRadius.circular(6), ), margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), child: Row( children: [ Icon( isLocal ? Icons.folder_open_rounded : Icons.folder_rounded, size: 16, color: selected ? const Color(0xFF1A73E8) : const Color(0xFFFBBC04), ), const SizedBox(width: 8), Expanded(child: Text(displayName, style: TextStyle(fontSize: 13, color: selected ? const Color(0xFF1A73E8) : const Color(0xFF333333), fontWeight: selected ? FontWeight.w600 : FontWeight.normal), overflow: TextOverflow.ellipsis)), if (isSyncing) Container( width: 7, height: 7, decoration: const BoxDecoration(color: Colors.green, shape: BoxShape.circle), ), ], ), ), ); } // ── Center Pane ────────────────────────────────────────────────────────────── Widget _buildCenterPane() { // Current folder display name final folderLabel = _selectedSidebarFolder == null ? 'All Files' : (_selectedSidebarFolder!.contains('/') ? _selectedSidebarFolder!.replaceAll(RegExp(r'/$'), '').split('/').last : p.basename(_selectedSidebarFolder!)); final totalCount = folders.length + localFolders.length + files.length; return Column( children: [ // Folder header bar (matches screenshot) Container( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), decoration: const BoxDecoration(color: Colors.white, border: Border(bottom: BorderSide(color: Color(0xFFEEEEEE)))), child: Row( children: [ // Coral folder icon Container( width: 48, height: 48, decoration: BoxDecoration(color: _coral, borderRadius: BorderRadius.circular(14)), child: const Icon(Icons.folder_rounded, color: Colors.white, size: 26), ), const SizedBox(width: 14), Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(folderLabel, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Color(0xFF1A1A2E))), Text('$totalCount items${_selectedSidebarFolder != null ? ' in folder' : ''}', style: const TextStyle(fontSize: 12, color: Color(0xFFAAAAAA))), ])), // Refresh IconButton(icon: const Icon(Icons.refresh, size: 18, color: Color(0xFFAAAAAA)), onPressed: fetchFileList, tooltip: 'Refresh'), // Sync Now button (if folder selected) if (_selectedSidebarFolder != null) GestureDetector( onTap: () async { final isLocal = localFolders.contains(_selectedSidebarFolder); if (isLocal) { _useLocalFolderAsProject(_selectedSidebarFolder!); } else { await _downloadAndExtractFolder(_selectedSidebarFolder!); } }, child: Container( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), decoration: BoxDecoration(color: _coral, borderRadius: BorderRadius.circular(10)), child: const Row(children: [ Icon(Icons.sync, size: 14, color: Colors.white), SizedBox(width: 5), Text('Sync', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: Colors.white)), ]), ), ), ], ), ), // Body Expanded( child: _selectedSidebarFolder == null ? _buildOverviewContent() : _buildFolderDetailContent(_selectedSidebarFolder!), ), ], ); } // Dummy LayoutBuilder row to avoid compile errors from removed toolbar: // Overview: recent folders grid + full folder table Widget _buildOverviewContent() { // Build (displayName, selectionKey) pairs so tapping a recent-folder card passes // the correct value to _selectedSidebarFolder: // • local folders → selectionKey = full disk path (so isLocal lookup works) // • remote folders → selectionKey = S3 prefix key final allFolderEntries = [ ...localFolders.map((f) => (p.basename(f), f)), ...folders.where((key) { final base = key.replaceAll(RegExp(r'/$'), '').split('/').last; return !localFolders.any((lf) => p.basename(lf) == base); }).map((key) => (key.replaceAll(RegExp(r'/$'), '').split('/').last, key)), ]; final allFolderNames = [ ...localFolders.map((f) => p.basename(f)), ...folders, ]; final recentFolders = allFolderNames.take(5).toList(); final recentEntries = allFolderEntries.take(5).toList(); return SingleChildScrollView( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Breadcrumb Row(children: [ const Text('Home', style: TextStyle(fontSize: 13, color: Color(0xFF888888))), const Text(' / ', style: TextStyle(fontSize: 13, color: Color(0xFF888888))), const Text('All Folders', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: Color(0xFF1A1A2E))), const Spacer(), IconButton( icon: const Icon(Icons.refresh, size: 18), onPressed: () => fetchFileList(), tooltip: 'Refresh', visualDensity: VisualDensity.compact, ), ]), const SizedBox(height: 12), // Recent folders grid if (recentEntries.isNotEmpty) ...[ const Text('Recent Files', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600)), const SizedBox(height: 10), SizedBox( height: 120, child: ListView.separated( scrollDirection: Axis.horizontal, itemCount: recentEntries.length, separatorBuilder: (_, __) => const SizedBox(width: 10), itemBuilder: (context, i) { final (displayName, selectionKey) = recentEntries[i]; return GestureDetector( onTap: () => setState(() { _selectedSidebarFolder = selectionKey; _folderNavStack.clear(); }), child: Container( width: 100, decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(10), border: Border.all(color: const Color(0xFFE5E7EB)), ), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const Icon(Icons.folder_rounded, color: Color(0xFFFBBC04), size: 40), const SizedBox(height: 6), Text(displayName, style: const TextStyle(fontSize: 11), textAlign: TextAlign.center, overflow: TextOverflow.ellipsis, maxLines: 2), ], ), ), ); }, ), ), const SizedBox(height: 20), ], // Folders table Container( decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(10), border: Border.all(color: const Color(0xFFE5E7EB)), ), child: Column( children: [ // Header Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), child: Row(children: [ const SizedBox(width: 24), const Expanded(flex: 4, child: Text('Name', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: Color(0xFF888888)))), const Expanded(flex: 2, child: Text('Type', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: Color(0xFF888888)))), const Expanded(flex: 2, child: Text('Status', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: Color(0xFF888888)))), const SizedBox(width: 100), ]), ), const Divider(height: 1), // Remote folders ...folders.where((f) => _searchQuery.isEmpty || f.toLowerCase().contains(_searchQuery)).map((folder) { final cfg = _syncSettings[folder] is Map ? _syncSettings[folder] as Map : null; final enabled = cfg != null && cfg['enabled'] == true; final interval = cfg != null && cfg['interval'] is int ? cfg['interval'] as int : 15; return _folderTableRow( name: folder.replaceAll(RegExp(r'/$'), '').split('/').last, fullPath: folder, type: 'Remote Folder', enabled: enabled, interval: interval, isLocal: false, ); }), // Local folders ...localFolders.where((f) => _searchQuery.isEmpty || f.toLowerCase().contains(_searchQuery)).map((full) { final cfg = _syncSettings[full] is Map ? _syncSettings[full] as Map : null; final enabled = cfg != null && cfg['enabled'] == true; final interval = cfg != null && cfg['interval'] is int ? cfg['interval'] as int : 15; return _folderTableRow( name: p.basename(full), fullPath: full, type: 'Local Folder', enabled: enabled, interval: interval, isLocal: true, ); }), if (folders.isEmpty && localFolders.isEmpty) const Padding( padding: EdgeInsets.all(32), child: Center(child: Text('No folders found. Connect to sync.', style: TextStyle(color: Color(0xFF888888)))), ), ], ), ), ], ), ); } Widget _folderTableRow({required String name, required String fullPath, required String type, required bool enabled, required int interval, required bool isLocal}) { final isSyncing = _currentFolderPrefix == fullPath || (isLocal && _currentProjectDir?.path == fullPath); return InkWell( onTap: () => setState(() { _selectedSidebarFolder = fullPath; _folderNavStack.clear(); }), child: Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), decoration: const BoxDecoration(border: Border(bottom: BorderSide(color: Color(0xFFF3F4F6)))), child: Row( children: [ const SizedBox(width: 4), Icon(Icons.folder_rounded, color: const Color(0xFFFBBC04), size: 18), const SizedBox(width: 20), Expanded(flex: 4, child: Text(name, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500), overflow: TextOverflow.ellipsis)), Expanded(flex: 2, child: Text(type, style: const TextStyle(fontSize: 12, color: Color(0xFF888888)))), Expanded( flex: 2, child: Row(children: [ Container( width: 7, height: 7, decoration: BoxDecoration( color: isSyncing ? Colors.green : (enabled ? const Color(0xFF1A73E8) : Colors.grey), shape: BoxShape.circle, ), ), const SizedBox(width: 6), Text(isSyncing ? 'Syncing' : (enabled ? 'Auto ($interval min)' : 'Manual'), style: const TextStyle(fontSize: 12, color: Color(0xFF888888))), ]), ), Row( mainAxisSize: MainAxisSize.min, children: [ IconButton( icon: const Icon(Icons.sync, size: 16, color: Color(0xFF1A73E8)), onPressed: () async { if (isLocal) { _useLocalFolderAsProject(fullPath); } else { await _downloadAndExtractFolder(fullPath); } }, tooltip: 'Sync now', visualDensity: VisualDensity.compact, ), IconButton( icon: const Icon(Icons.settings_outlined, size: 16, color: Color(0xFF888888)), onPressed: () => _showIntervalDialog(fullPath), tooltip: 'Settings', visualDensity: VisualDensity.compact, ), Switch.adaptive( value: enabled, onChanged: (v) => _toggleSyncForFolder(fullPath, v), ), ], ), ], ), ), ); } // Folder detail: browses the folder tree at the current nav level Widget _buildFolderDetailContent(String folderPath) { final isLocal = localFolders.contains(folderPath); final folderBaseName = p.basename(folderPath.replaceAll(RegExp(r'/$'), '')); // Current browsed path: top of nav-stack (or the root folder itself) final String currentBrowsePath = _folderNavStack.isNotEmpty ? _folderNavStack.last : folderPath; // Breadcrumb: root segment + one per nav-stack entry final List crumbLabels = [ folderBaseName, ..._folderNavStack.map((seg) => isLocal ? p.basename(seg) : seg.replaceAll(RegExp(r'/$'), '').split('/').last), ]; final pendingChanges = _changes .where((c) => c.status != 'synced' && (c.path.contains(folderBaseName) || (c.remoteItem?.key.contains(folderBaseName) ?? false))) .toList(); return SingleChildScrollView( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // ── Breadcrumb + actions ───────────────────────────────────────── Row( children: [ GestureDetector( onTap: () => setState(() { _selectedSidebarFolder = null; _folderNavStack.clear(); }), child: const Text('Home', style: TextStyle(fontSize: 13, color: Color(0xFF1A73E8), decoration: TextDecoration.underline)), ), ...List.generate(crumbLabels.length, (i) { final isLast = i == crumbLabels.length - 1; final label = crumbLabels[i]; return Row(mainAxisSize: MainAxisSize.min, children: [ const Text(' / ', style: TextStyle(fontSize: 13, color: Color(0xFF888888))), isLast ? Text(label, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: Color(0xFF1A1A2E))) : GestureDetector( onTap: () => setState(() { if (i == 0) { _folderNavStack.clear(); } else { while (_folderNavStack.length > i) _folderNavStack.removeLast(); } }), child: Text(label, style: const TextStyle(fontSize: 13, color: Color(0xFF1A73E8), decoration: TextDecoration.underline)), ), ]); }), const Spacer(), ElevatedButton.icon( onPressed: () async { if (isLocal) { _useLocalFolderAsProject(folderPath); } else { await _downloadAndExtractFolder(folderPath); } }, icon: const Icon(Icons.sync, size: 14), label: const Text('Sync Folder'), style: ElevatedButton.styleFrom( backgroundColor: const Color(0xFF1A73E8), foregroundColor: Colors.white, visualDensity: VisualDensity.compact, textStyle: const TextStyle(fontSize: 12), ), ), const SizedBox(width: 8), OutlinedButton.icon( onPressed: () { final openPath = isLocal ? currentBrowsePath : (_storageBasePath.isNotEmpty ? p.join(_storageBasePath, folderBaseName) : folderPath); Process.run('open', [openPath]); }, icon: const Icon(Icons.open_in_new, size: 14), label: const Text('Open'), style: OutlinedButton.styleFrom(visualDensity: VisualDensity.compact, textStyle: const TextStyle(fontSize: 12)), ), ], ), const SizedBox(height: 16), // ── Contents table ──────────────────────────────────────────────── Container( decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(10), border: Border.all(color: const Color(0xFFE5E7EB)), ), child: FutureBuilder<_FolderListing>( future: _listFolderLevel( isLocal: isLocal, currentBrowsePath: currentBrowsePath, folderBaseName: folderBaseName, ), builder: (ctx, snap) { if (snap.connectionState == ConnectionState.waiting) { return const Padding( padding: EdgeInsets.all(48), child: Center(child: CircularProgressIndicator(strokeWidth: 2)), ); } final listing = snap.data ?? _FolderListing(subFolders: [], fileRows: []); final q = _searchQuery.toLowerCase(); final filteredFolders = listing.subFolders.where((s) => q.isEmpty || s.name.toLowerCase().contains(q)).toList(); final filteredFiles = listing.fileRows.where((r) => q.isEmpty || r.name.toLowerCase().contains(q)).toList(); final hasContent = filteredFolders.isNotEmpty || filteredFiles.isNotEmpty; return Column(children: [ // Header Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), child: Row(children: [ const Expanded(flex: 4, child: Text('Name', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: Color(0xFF888888)))), const Expanded(flex: 2, child: Text('Type', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: Color(0xFF888888)))), const Expanded(flex: 1, child: Text('Size', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: Color(0xFF888888)))), const Expanded(flex: 2, child: Text('Status', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: Color(0xFF888888)))), const SizedBox(width: 40), ]), ), const Divider(height: 1), if (!hasContent) const Padding( padding: EdgeInsets.all(32), child: Center(child: Text('This folder is empty.', style: TextStyle(color: Color(0xFF888888)))), ) else ...[ ...filteredFolders.map((sub) => _subFolderRow(sub)), ...filteredFiles.map((row) => _localFileRow(row)), ], ]); }, ), ), // ── Pending changes ─────────────────────────────────────────────── if (pendingChanges.isNotEmpty) ...[ const SizedBox(height: 16), Container( decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(10), border: Border.all(color: const Color(0xFFE5E7EB)), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), child: Row(children: [ const Icon(Icons.pending_outlined, size: 16, color: Color(0xFF1A73E8)), const SizedBox(width: 8), Text('Pending Changes (${pendingChanges.length})', style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600)), ]), ), const Divider(height: 1), ...pendingChanges.map((change) => _pendingChangeRow(change)), ], ), ), ], ], ), ); } /// Lists sub-folders and files at the given browse level. Future<_FolderListing> _listFolderLevel({ required bool isLocal, required String currentBrowsePath, required String folderBaseName, }) async { if (isLocal) { final dir = Directory(currentBrowsePath); if (!await dir.exists()) return _FolderListing(subFolders: [], fileRows: []); final entities = await dir.list(recursive: false, followLinks: false).toList(); entities.sort((a, b) { final ad = a is Directory ? 0 : 1; final bd = b is Directory ? 0 : 1; if (ad != bd) return ad - bd; return p.basename(a.path).toLowerCase().compareTo(p.basename(b.path).toLowerCase()); }); final subFolders = <_SubFolderEntry>[]; final fileRows = <_FileRow>[]; for (final e in entities) { final name = p.basename(e.path); if (name.startsWith('.')) continue; if (e is Directory) { subFolders.add(_SubFolderEntry(name: name, fullPath: e.path)); } else if (e is File) { FileStat? stat; try { stat = e.statSync(); } catch (_) {} fileRows.add(_FileRow( name: name, ext: name.contains('.') ? name.split('.').last.toLowerCase() : '', sizeBytes: stat?.size, status: 'local', )); } } return _FolderListing(subFolders: subFolders, fileRows: fileRows); } else { // Remote: derive from in-memory files list final prefix = currentBrowsePath.endsWith('/') ? currentBrowsePath : '$currentBrowsePath/'; final matchingFiles = files.where((f) => f.key.startsWith(prefix) || f.key.contains('/$folderBaseName/') || f.key.startsWith('$folderBaseName/')).toList(); final effectivePrefix = (matchingFiles.isNotEmpty && matchingFiles.first.key.startsWith(prefix)) ? prefix : ''; final subFolderNames = {}; final fileRows = <_FileRow>[]; for (final f in matchingFiles) { final rel = effectivePrefix.isNotEmpty && f.key.startsWith(effectivePrefix) ? f.key.substring(effectivePrefix.length) : f.key; final slash = rel.indexOf('/'); if (slash > 0) { subFolderNames.add(rel.substring(0, slash)); } else if (slash == -1 && rel.isNotEmpty) { final ext = f.name.contains('.') ? f.name.split('.').last.toLowerCase() : ''; final change = _changes.where((c) => c.remoteItem?.key == f.key).firstOrNull; fileRows.add(_FileRow( name: f.name, ext: ext, sizeBytes: f.size, status: change?.status ?? 'synced', fileItem: f, )); } } final sorted = subFolderNames.toList() ..sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase())); return _FolderListing( subFolders: sorted.map((name) => _SubFolderEntry( name: name, fullPath: '$effectivePrefix$name/', fileCount: matchingFiles.where((f) { final rel = effectivePrefix.isNotEmpty && f.key.startsWith(effectivePrefix) ? f.key.substring(effectivePrefix.length) : f.key; return rel.startsWith('$name/'); }).length, )).toList(), fileRows: fileRows, ); } } Widget _subFolderRow(_SubFolderEntry sub) { return InkWell( onTap: () => setState(() => _folderNavStack.add(sub.fullPath)), child: Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), decoration: const BoxDecoration(border: Border(bottom: BorderSide(color: Color(0xFFF3F4F6)))), child: Row(children: [ const Icon(Icons.folder_rounded, size: 18, color: Color(0xFFFBBC04)), const SizedBox(width: 10), Expanded( flex: 4, child: Text(sub.name, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500), overflow: TextOverflow.ellipsis), ), const Expanded(flex: 2, child: Text('Folder', style: TextStyle(fontSize: 12, color: Color(0xFF888888)))), Expanded( flex: 1, child: Text( sub.fileCount != null ? '${sub.fileCount} items' : '—', style: const TextStyle(fontSize: 12, color: Color(0xFF888888)), ), ), const Expanded(flex: 2, child: SizedBox()), const Icon(Icons.chevron_right, size: 16, color: Color(0xFFBBBBBB)), const SizedBox(width: 8), ]), ), ); } Widget _localFileRow(_FileRow row) { if (row.fileItem != null) return _fileTableRow(row.fileItem!); final sizeStr = row.sizeBytes != null ? (row.sizeBytes! > 1024 * 1024 ? '${(row.sizeBytes! / (1024 * 1024)).toStringAsFixed(1)} MB' : '${(row.sizeBytes! / 1024).toStringAsFixed(1)} KB') : '—'; return Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), decoration: const BoxDecoration(border: Border(bottom: BorderSide(color: Color(0xFFF3F4F6)))), child: Row(children: [ Icon(_fileIcon(row.ext), size: 18, color: const Color(0xFF1A73E8)), const SizedBox(width: 10), Expanded(flex: 4, child: Text(row.name, style: const TextStyle(fontSize: 13), overflow: TextOverflow.ellipsis)), Expanded(flex: 2, child: Text(row.ext.toUpperCase().isNotEmpty ? row.ext.toUpperCase() : 'File', style: const TextStyle(fontSize: 12, color: Color(0xFF888888)))), Expanded(flex: 1, child: Text(sizeStr, style: const TextStyle(fontSize: 12, color: Color(0xFF888888)))), Expanded( flex: 2, child: Row(children: [ Container(width: 7, height: 7, decoration: BoxDecoration(color: _statusColor(row.status), shape: BoxShape.circle)), const SizedBox(width: 6), Text(_statusLabel(row.status), style: const TextStyle(fontSize: 12, color: Color(0xFF888888))), ]), ), const SizedBox(width: 40), ]), ); } Widget _fileTableRow(FileItem file) { final ext = file.name.contains('.') ? file.name.split('.').last.toLowerCase() : ''; final sizeStr = file.size != null ? '${(file.size! / 1024).toStringAsFixed(1)} KB' : '—'; final change = _changes.where((c) => c.path.contains(file.name) || (c.remoteItem?.key == file.key)).firstOrNull; final status = change?.status ?? 'synced'; return Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), decoration: const BoxDecoration(border: Border(bottom: BorderSide(color: Color(0xFFF3F4F6)))), child: Row( children: [ Icon(_fileIcon(ext), size: 18, color: const Color(0xFF1A73E8)), const SizedBox(width: 10), Expanded(flex: 4, child: Text(file.name, style: const TextStyle(fontSize: 13), overflow: TextOverflow.ellipsis)), Expanded(flex: 2, child: Text(ext.toUpperCase().isNotEmpty ? ext.toUpperCase() : 'File', style: const TextStyle(fontSize: 12, color: Color(0xFF888888)))), Expanded(flex: 1, child: Text(sizeStr, style: const TextStyle(fontSize: 12, color: Color(0xFF888888)))), Expanded( flex: 2, child: Row(children: [ Container( width: 7, height: 7, decoration: BoxDecoration(color: _statusColor(status), shape: BoxShape.circle), ), const SizedBox(width: 6), Text(_statusLabel(status), style: const TextStyle(fontSize: 12, color: Color(0xFF888888))), ]), ), SizedBox( width: 40, child: change != null && status != 'synced' ? IconButton( icon: Icon(_changeActionIcon(status), size: 16, color: const Color(0xFF1A73E8)), onPressed: () async { if (status == 'new_local' || status == 'modified_local') { await _uploadFile(change.localFile!, change.path); } else if (status == 'new_remote' && change.remoteItem != null) { await downloadFile(change.remoteItem!); _checkForChanges(); } }, tooltip: _changeActionLabel(status), visualDensity: VisualDensity.compact, ) : const SizedBox.shrink(), ), ], ), ); } Widget _pendingChangeRow(ChangeItem change) { final status = change.status; return Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), decoration: const BoxDecoration(border: Border(bottom: BorderSide(color: Color(0xFFF3F4F6)))), child: Row( children: [ Icon(_statusIcon(status), size: 16, color: _statusColor(status)), const SizedBox(width: 10), Expanded(child: Text(change.path, style: const TextStyle(fontSize: 12), overflow: TextOverflow.ellipsis)), const SizedBox(width: 8), Text(status.replaceAll('_', ' '), style: TextStyle(fontSize: 11, color: _statusColor(status))), const SizedBox(width: 8), if (status != 'synced') TextButton( onPressed: () async { if (status == 'new_local' || status == 'modified_local') { await _uploadFile(change.localFile!, change.path); } else if (status == 'new_remote' && change.remoteItem != null) { await downloadFile(change.remoteItem!); _checkForChanges(); } }, style: TextButton.styleFrom(visualDensity: VisualDensity.compact, textStyle: const TextStyle(fontSize: 11)), child: Text(_changeActionLabel(status)), ), ], ), ); } // ── Right Sync Panel ───────────────────────────────────────────────────────── Widget _buildRightPanel() { return _showFileDetails && _selectedFile != null ? _buildFileDetailsPanel(_selectedFile!) : _buildSyncStatusPanel(); } // File Details panel — shown when user taps a file Widget _buildFileDetailsPanel(FileItem file) { final ext = file.name.contains('.') ? file.name.split('.').last.toLowerCase() : ''; final sizeStr = file.size != null ? (file.size! > 1024 * 1024 ? '${(file.size! / (1024 * 1024)).toStringAsFixed(1)} MB' : '${(file.size! / 1024).toStringAsFixed(1)} KB') : '—'; return Container( width: 272, decoration: const BoxDecoration(color: Colors.white, border: Border(left: BorderSide(color: Color(0xFFEEEEEE)))), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Panel header Padding( padding: const EdgeInsets.fromLTRB(16, 14, 14, 10), child: Row(children: [ const Icon(Icons.insert_drive_file_outlined, size: 14, color: Color(0xFFAAAAAA)), const SizedBox(width: 6), const Text('File Details', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600)), const Spacer(), // Toggle back to sync status IconButton( icon: const Icon(Icons.sync, size: 16, color: Color(0xFFAAAAAA)), onPressed: () => setState(() => _showFileDetails = false), tooltip: 'Sync Status', visualDensity: VisualDensity.compact, ), IconButton( icon: const Icon(Icons.close, size: 16, color: Color(0xFFAAAAAA)), onPressed: () => setState(() { _selectedFile = null; _showFileDetails = false; }), visualDensity: VisualDensity.compact, ), ]), ), const Divider(height: 1, color: Color(0xFFF0F0F0)), Expanded(child: SingleChildScrollView( padding: const EdgeInsets.all(12), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ // Preview card Container( height: 120, width: double.infinity, decoration: BoxDecoration( color: _fileExtColor(ext).withValues(alpha: 0.08), borderRadius: BorderRadius.circular(12), ), child: Center(child: Icon(_fileIcon(ext), size: 52, color: _fileExtColor(ext))), ), const SizedBox(height: 12), // Name + size Text(file.name, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: Color(0xFF1A1A2E))), const SizedBox(height: 2), Text(sizeStr, style: const TextStyle(fontSize: 12, color: Color(0xFFAAAAAA))), const SizedBox(height: 12), // Action buttons Row(children: [ Expanded(child: GestureDetector( onTap: () async { final presigned = await fetchPresignedUrl(file.key); if (presigned != null) _openUrl(presigned); }, child: Container( padding: const EdgeInsets.symmetric(vertical: 8), decoration: BoxDecoration(color: const Color(0xFFFFF3EC), borderRadius: BorderRadius.circular(8)), child: const Row(mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(Icons.download_outlined, size: 14, color: _coral), SizedBox(width: 4), Text('Download', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: _coral)), ]), ), )), const SizedBox(width: 8), GestureDetector( onTap: () { if (_currentProjectDir != null) { final localPath = p.join(_currentProjectDir!.path, file.name); if (File(localPath).existsSync()) openInFinder(localPath); else _showSnack('File not found locally. Sync first.'); } }, child: Container( width: 36, height: 36, decoration: BoxDecoration(border: Border.all(color: const Color(0xFFE5E7EB)), borderRadius: BorderRadius.circular(8)), child: const Icon(Icons.folder_open_outlined, size: 16, color: Color(0xFF888888)), ), ), ]), const SizedBox(height: 16), const Divider(height: 1, color: Color(0xFFF0F0F0)), const SizedBox(height: 12), // Description section Row(children: [ const Icon(Icons.description_outlined, size: 14, color: Color(0xFFAAAAAA)), const SizedBox(width: 6), const Text('Details', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: Color(0xFF444444))), const Spacer(), const Icon(Icons.keyboard_arrow_up, size: 16, color: Color(0xFFAAAAAA)), ]), const SizedBox(height: 10), _detailRow('Type', ext.toUpperCase().isNotEmpty ? ext.toUpperCase() : 'File'), _detailRow('Size', sizeStr), _detailRow('S3 Key', file.key, small: true), // Sync status for this file const SizedBox(height: 12), const Divider(height: 1, color: Color(0xFFF0F0F0)), const SizedBox(height: 12), Row(children: [ const Icon(Icons.sync_outlined, size: 14, color: Color(0xFFAAAAAA)), const SizedBox(width: 6), const Text('Sync Status', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: Color(0xFF444444))), ]), const SizedBox(height: 8), Builder(builder: (ctx) { final change = _changes.where((c) => c.remoteItem?.key == file.key).firstOrNull; final status = change?.status ?? 'synced'; return Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), decoration: BoxDecoration( color: _statusColor(status).withValues(alpha: 0.08), borderRadius: BorderRadius.circular(8), border: Border.all(color: _statusColor(status).withValues(alpha: 0.25)), ), child: Row(children: [ Icon(_statusIcon(status), size: 13, color: _statusColor(status)), const SizedBox(width: 6), Text(_statusLabel(status), style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: _statusColor(status))), ]), ); }), ]), )), ], ), ); } Widget _detailRow(String label, String value, {bool small = false}) { return Padding( padding: const EdgeInsets.only(bottom: 6), child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox(width: 56, child: Text(label, style: const TextStyle(fontSize: 11, color: Color(0xFFAAAAAA)))), Expanded(child: Text(value, style: TextStyle(fontSize: small ? 10 : 11, fontWeight: FontWeight.w500, color: const Color(0xFF444444)), overflow: TextOverflow.ellipsis, maxLines: small ? 2 : 1)), ]), ); } // Sync Status panel — shown by default / when no file selected Widget _buildSyncStatusPanel() { final pending = _changes.where((c) => c.status != 'synced').toList(); return Container( width: 272, decoration: const BoxDecoration( color: Colors.white, border: Border(left: BorderSide(color: Color(0xFFEEEEEE))), ), child: Column( children: [ // Panel header Padding( padding: const EdgeInsets.fromLTRB(16, 14, 14, 10), child: Row(children: [ Container(width: 6, height: 6, margin: const EdgeInsets.only(right: 8), decoration: BoxDecoration(color: _currentProjectDir != null ? Colors.green : Colors.grey, shape: BoxShape.circle)), const Text('Sync Status', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600)), const Spacer(), IconButton( icon: const Icon(Icons.refresh, size: 16), onPressed: _refreshEvents, tooltip: 'Refresh log', visualDensity: VisualDensity.compact, ), ]), ), const Divider(height: 1), Expanded( child: SingleChildScrollView( padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Active sync / paused card if (_currentProjectDir != null && _currentFolderPrefix != null) _syncStatusCard( title: 'Syncing', subtitle: _currentFolderPrefix!, local: _currentProjectDir!.path, color: Colors.green, icon: Icons.sync, onSync: () async { setState(() { loading = true; }); try { await _performPeriodicSync(); await _checkForChanges(); _showSnack('✅ Sync completed'); } catch (e) { _showSnack('❌ Sync failed: $e'); } finally { setState(() { loading = false; }); } }, onStop: () async { await _watchSub?.cancel(); _watchSub = null; setState(() { _currentProjectDir = null; _currentFolderPrefix = null; _changes.clear(); }); _showSnack('Stopped syncing'); }, ) else if (_lastSyncedFolder != null && _lastSyncedLocalPath != null) _syncStatusCard( title: 'Paused', subtitle: _lastSyncedFolder!, local: _lastSyncedLocalPath!, color: Colors.orange, icon: Icons.pause_circle_outline, onSync: () async { final dir = Directory(_lastSyncedLocalPath!); if (!await dir.exists()) { _showSnack('Local folder no longer exists.'); return; } _currentProjectDir = dir; _currentFolderPrefix = _lastSyncedFolder; await _primeKnownKeys(_currentFolderPrefix!); _startWatcher(dir, _currentFolderPrefix!); _showSnack('Resumed syncing $_lastSyncedFolder'); setState(() {}); }, onStop: null, ) else Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration(color: const Color(0xFFF8F9FA), borderRadius: BorderRadius.circular(8), border: Border.all(color: const Color(0xFFE5E7EB))), child: const Row(children: [ Icon(Icons.info_outline, size: 16, color: Color(0xFF888888)), SizedBox(width: 8), Expanded(child: Text('No active sync. Select a folder to sync.', style: TextStyle(fontSize: 12, color: Color(0xFF888888)))), ]), ), // const SizedBox(height: 16), // // Storage path // const Text('STORAGE', style: TextStyle(fontSize: 10, fontWeight: FontWeight.w600, color: Color(0xFFAAAAAA), letterSpacing: 0.5)), // const SizedBox(height: 6), // Container( // padding: const EdgeInsets.all(10), // decoration: BoxDecoration(color: const Color(0xFFF8F9FA), borderRadius: BorderRadius.circular(8), border: Border.all(color: const Color(0xFFE5E7EB))), // child: Row(children: [ // const Icon(Icons.folder_outlined, size: 14, color: Color(0xFF888888)), // const SizedBox(width: 6), // Expanded(child: Text(_storageBasePath.isNotEmpty ? _storageBasePath : 'Not set', style: const TextStyle(fontSize: 11, color: Color(0xFF555555)), overflow: TextOverflow.ellipsis)), // IconButton( // icon: const Icon(Icons.edit_outlined, size: 14), // onPressed: _showStorageDialog, // visualDensity: VisualDensity.compact, // tooltip: 'Change', // ), // ]), // ), const SizedBox(height: 16), // Pending changes summary Row(children: [ const Text('PENDING', style: TextStyle(fontSize: 10, fontWeight: FontWeight.w600, color: Color(0xFFAAAAAA), letterSpacing: 0.5)), const SizedBox(width: 6), Container( padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1), decoration: BoxDecoration(color: pending.isNotEmpty ? const Color(0xFFE8F0FE) : const Color(0xFFF3F4F6), borderRadius: BorderRadius.circular(10)), child: Text('${pending.length}', style: TextStyle(fontSize: 10, color: pending.isNotEmpty ? const Color(0xFF1A73E8) : const Color(0xFF888888), fontWeight: FontWeight.w600)), ), ]), const SizedBox(height: 8), if (pending.isEmpty) const Text('All files synced ✓', style: TextStyle(fontSize: 12, color: Colors.green)) else ...pending.take(8).map((c) => Padding( padding: const EdgeInsets.symmetric(vertical: 3), child: Row(children: [ Icon(_statusIcon(c.status), size: 13, color: _statusColor(c.status)), const SizedBox(width: 6), Expanded(child: Text(c.path.split('/').last, style: const TextStyle(fontSize: 11), overflow: TextOverflow.ellipsis)), ]), )), if (pending.length > 8) Text('+${pending.length - 8} more…', style: const TextStyle(fontSize: 11, color: Color(0xFF888888))), const SizedBox(height: 16), // Sync log Row(children: [ const Text('LOG', style: TextStyle(fontSize: 10, fontWeight: FontWeight.w600, color: Color(0xFFAAAAAA), letterSpacing: 0.5)), const Spacer(), Row(children: [ SizedBox( height: 20, child: Switch.adaptive( value: _logFilterCurrentOnly, onChanged: (v) async { setState(() { _logFilterCurrentOnly = v; }); await _refreshEvents(); }, ), ), const SizedBox(width: 4), const Text('Current only', style: TextStyle(fontSize: 10, color: Color(0xFF888888))), ]), ]), const SizedBox(height: 8), if (_logLoading) const Center(child: SizedBox(height: 24, width: 24, child: CircularProgressIndicator(strokeWidth: 2))) else if (_recentEvents.isEmpty) const Text('No events yet.', style: TextStyle(fontSize: 12, color: Color(0xFF888888))) else ...(_recentEvents.take(12).map((e) { final ts = ((e['ts'] as String?) ?? '').split('T').join(' ').split('.').first; final type = (e['event_type'] as String?) ?? ''; final s3Key = (e['s3_key'] as String?) ?? (e['local_path'] as String?) ?? ''; final short = s3Key.split('/').last; final status = (e['status'] as String?) ?? ''; Color dot = status == 'success' ? Colors.green : (status == 'error' ? Colors.red : Colors.orange); return Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ Container(margin: const EdgeInsets.only(top: 4), width: 6, height: 6, decoration: BoxDecoration(color: dot, shape: BoxShape.circle)), const SizedBox(width: 6), Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('$type${short.isNotEmpty ? ': $short' : ''}', style: const TextStyle(fontSize: 11), overflow: TextOverflow.ellipsis), Text(ts, style: const TextStyle(fontSize: 10, color: Color(0xFF888888))), ])), ]), ); })), ], ), ), ), ], ), ); } Widget _syncStatusCard({ required String title, required String subtitle, required String local, required Color color, required IconData icon, required VoidCallback onSync, VoidCallback? onStop, }) { return Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: color.withValues(alpha: 0.06), borderRadius: BorderRadius.circular(8), border: Border.all(color: color.withValues(alpha: 0.3)), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row(children: [ Icon(icon, color: color, size: 16), const SizedBox(width: 6), Text(title, style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: color)), ]), const SizedBox(height: 6), Text(subtitle, style: const TextStyle(fontSize: 11, fontWeight: FontWeight.w500), overflow: TextOverflow.ellipsis), Text(local, style: const TextStyle(fontSize: 10, color: Color(0xFF888888)), overflow: TextOverflow.ellipsis), const SizedBox(height: 8), Row(children: [ Expanded( child: SizedBox( height: 28, child: ElevatedButton.icon( onPressed: onSync, icon: Icon(icon, size: 12), label: Text(onStop != null ? 'Sync Now' : 'Resume'), style: ElevatedButton.styleFrom( backgroundColor: color, foregroundColor: Colors.white, textStyle: const TextStyle(fontSize: 11), visualDensity: VisualDensity.compact, ), ), ), ), if (onStop != null) ...[ const SizedBox(width: 6), SizedBox( height: 28, child: OutlinedButton( onPressed: onStop, style: OutlinedButton.styleFrom(textStyle: const TextStyle(fontSize: 11), visualDensity: VisualDensity.compact), child: const Text('Stop'), ), ), ], ]), ], ), ); } // ── Storage dialog ─────────────────────────────────────────────────────────── void _showStorageDialog() { showDialog( context: rootNavigatorKey.currentContext!, builder: (ctx) => AlertDialog( title: const Row(children: [ Icon(Icons.lock_outline, size: 18, color: Color(0xFF1A73E8)), SizedBox(width: 8), Text('Managed Storage'), ]), content: SizedBox( width: 420, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: const Color(0xFFF0F4FF), borderRadius: BorderRadius.circular(8), border: Border.all(color: const Color(0xFFBDD0FF)), ), child: Row( children: [ const Icon(Icons.folder_special_outlined, color: Color(0xFF1A73E8), size: 20), const SizedBox(width: 10), Expanded( child: Text( _storageBasePath, style: const TextStyle(fontSize: 13, fontFamily: 'monospace'), ), ), IconButton( icon: const Icon(Icons.copy_outlined, size: 16), tooltip: 'Copy path', visualDensity: VisualDensity.compact, onPressed: () { Clipboard.setData(ClipboardData(text: _storageBasePath)); _showSnack('Path copied to clipboard'); }, ), ], ), ), const SizedBox(height: 12), const Text( 'This folder is managed automatically by PlanXO. It is hidden in Finder and protected against accidental deletion.', style: TextStyle(fontSize: 12, color: Color(0xFF666666)), ), const SizedBox(height: 8), OutlinedButton.icon( onPressed: () { Process.run('open', [p.dirname(_storageBasePath)]); }, icon: const Icon(Icons.open_in_new, size: 14), label: const Text('Reveal in Finder'), style: OutlinedButton.styleFrom( visualDensity: VisualDensity.compact, textStyle: const TextStyle(fontSize: 12), ), ), ], ), ), actions: [ ElevatedButton( onPressed: () => Navigator.pop(ctx), child: const Text('Done'), ), ], ), ); } // ── Helper methods for new UI ──────────────────────────────────────────────── Color _statusColor(String status) { switch (status) { case 'synced': return Colors.green; case 'new_local': return Colors.green; case 'modified_local': return Colors.orange; case 'new_remote': return const Color(0xFF1A73E8); default: return Colors.grey; } } String _statusLabel(String status) { switch (status) { case 'synced': return 'Synced'; case 'new_local': return 'Upload'; case 'modified_local': return 'Modified'; case 'new_remote': return 'Download'; default: return status.replaceAll('_', ' '); } } IconData _statusIcon(String status) { switch (status) { case 'synced': return Icons.check_circle_outline; case 'new_local': return Icons.upload_outlined; case 'modified_local': return Icons.edit_outlined; case 'new_remote': return Icons.download_outlined; default: return Icons.help_outline; } } IconData _changeActionIcon(String status) { switch (status) { case 'new_local': return Icons.upload_outlined; case 'modified_local': return Icons.upload_outlined; case 'new_remote': return Icons.download_outlined; default: return Icons.sync; } } String _changeActionLabel(String status) { switch (status) { case 'new_local': return 'Upload'; case 'modified_local': return 'Upload Update'; case 'new_remote': return 'Download'; default: return 'Sync'; } } IconData _fileIcon(String ext) { switch (ext) { case 'pdf': return Icons.picture_as_pdf_outlined; case 'jpg': case 'jpeg': case 'png': case 'gif': case 'webp': case 'svg': return Icons.image_outlined; case 'mp4': case 'mov': case 'avi': return Icons.video_file_outlined; case 'mp3': case 'wav': case 'aac': return Icons.audio_file_outlined; case 'zip': case 'rar': case 'tar': case 'gz': return Icons.folder_zip_outlined; case 'doc': case 'docx': return Icons.description_outlined; case 'xls': case 'xlsx': return Icons.table_chart_outlined; case 'ppt': case 'pptx': return Icons.slideshow_outlined; default: return Icons.insert_drive_file_outlined; } } // (Tab 2 logic kept — pending changes full view accessible via top bar later) // Dummy shim so existing references compile Widget _buildTitleBar() => const SizedBox.shrink(); Widget _buildWsIndicator() { Color color; if (_wsOpen) { color = Colors.green; } else if (_wsConnecting) { color = Colors.orange; } else { color = Colors.red; } return AnimatedContainer( duration: const Duration(milliseconds: 300), width: 12, height: 12, decoration: BoxDecoration( color: color, shape: BoxShape.circle, boxShadow: [ BoxShadow(color: color.withValues(alpha: 0.6), blurRadius: 4, spreadRadius: 1), ], ), ); } void _handleAuthRevoked(Map data) { WidgetsBinding.instance.addPostFrameCallback((_) async { await _clearAuthKey(); _userInfo = null; _isAuthenticated = false; _authPollTimer?.cancel(); _periodicSyncTimer?.cancel(); for (final t in _syncTimers.values) { if (t.isActive) t.cancel(); } _syncTimers.clear(); _syncRunning.clear(); _showSnack('Authentication revoked. Please log in again.'); if (mounted) setState(() {}); }); } Widget _buildLoginScreen() { final secretController = TextEditingController(text: _assetManagerSecret); bool showAdvanced = _assetManagerSecret.isEmpty; // show on first run return StatefulBuilder(builder: (ctx, setLocal) { return Scaffold( backgroundColor: const Color(0xFFF5F6FA), body: Center( child: Container( constraints: const BoxConstraints(maxWidth: 420), child: Column( mainAxisSize: MainAxisSize.min, children: [ Container( padding: const EdgeInsets.all(32), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(16), boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.08), blurRadius: 24, offset: const Offset(0, 8))], ), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Row(mainAxisAlignment: MainAxisAlignment.center, children: [ Container(width: 38, height: 38, decoration: BoxDecoration(color: _coral, borderRadius: BorderRadius.circular(10)), child: const Icon(Icons.folder_rounded, color: Colors.white, size: 22)), const SizedBox(width: 10), const Text('PlanXO', style: TextStyle(fontSize: 26, fontWeight: FontWeight.bold, color: Color(0xFF1A1A2E))), ]), const SizedBox(height: 6), const Text('Asset Sync', textAlign: TextAlign.center, style: TextStyle(fontSize: 14, color: Color(0xFF888888))), const SizedBox(height: 28), TextField( controller: _clientController, decoration: InputDecoration( labelText: 'Client Name', hintText: 'e.g. cms', border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), prefixIcon: const Icon(Icons.business_outlined), ), ), const SizedBox(height: 12), // Advanced / first-run section for asset manager secret GestureDetector( onTap: () => setLocal(() => showAdvanced = !showAdvanced), child: Row(children: [ Icon(showAdvanced ? Icons.expand_less : Icons.expand_more, size: 16, color: const Color(0xFF888888)), const SizedBox(width: 4), Text(showAdvanced ? 'Hide advanced settings' : 'Advanced settings', style: const TextStyle(fontSize: 12, color: Color(0xFF888888))), if (_assetManagerSecret.isEmpty) ...[ const SizedBox(width: 6), Container(padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration(color: Colors.orange.shade50, borderRadius: BorderRadius.circular(6)), child: Text('Setup required', style: TextStyle(fontSize: 10, color: Colors.orange.shade700, fontWeight: FontWeight.w600))), ], ]), ), if (showAdvanced) ...[ const SizedBox(height: 10), TextField( controller: secretController, obscureText: true, decoration: InputDecoration( labelText: 'Asset Manager Secret', hintText: 'Matches ASSET_MANAGER_SECRET on server', helperText: 'Ask your admin for this value', border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), prefixIcon: const Icon(Icons.key_outlined), ), onChanged: (v) async { _assetManagerSecret = v.trim(); await _secureStorage.write(key: 'planxo_asset_manager_secret', value: _assetManagerSecret); }, ), const SizedBox(height: 6), Text('This machine\'s ID: $uniqueId', style: const TextStyle(fontSize: 10, color: Color(0xFFAAAAAA))), ], const SizedBox(height: 16), FilledButton.icon( onPressed: () => _openLoginForClient(_clientController.text), icon: const Icon(Icons.login), label: const Text('Continue'), style: FilledButton.styleFrom( backgroundColor: _coral, padding: const EdgeInsets.symmetric(vertical: 14), textStyle: const TextStyle(fontSize: 15), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), ), ), ], ), ), const SizedBox(height: 20), Row(mainAxisAlignment: MainAxisAlignment.center, children: [ AnimatedContainer( duration: const Duration(milliseconds: 300), width: 8, height: 8, decoration: BoxDecoration( color: _wsOpen ? Colors.green : (_wsConnecting ? Colors.orange : Colors.red), shape: BoxShape.circle, ), ), const SizedBox(width: 8), Text( _wsOpen ? 'Connected to server' : (_wsConnecting ? 'Connecting…' : 'Offline'), style: TextStyle(fontSize: 12, color: _wsOpen ? Colors.green : (_wsConnecting ? Colors.orange : Colors.red)), ), ]), ], ), ), ), ); }); // end StatefulBuilder } } // ── Data classes for folder browsing ───────────────────────────────────────── class _SubFolderEntry { final String name; /// Full path: absolute disk path (local) or S3 prefix ending with '/' (remote). final String fullPath; final int? fileCount; _SubFolderEntry({required this.name, required this.fullPath, this.fileCount}); } class _FileRow { final String name; final String ext; final int? sizeBytes; final String status; /// Set when this row corresponds to a remote [FileItem]. final FileItem? fileItem; _FileRow({ required this.name, required this.ext, this.sizeBytes, required this.status, this.fileItem, }); } class _FolderListing { final List<_SubFolderEntry> subFolders; final List<_FileRow> fileRows; _FolderListing({required this.subFolders, required this.fileRows}); }