Files
automiz_app_full/development/planxo/lib/main.dart
2026-08-28 19:24:04 +05:30

4268 lines
167 KiB
Dart

// 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<ScaffoldMessengerState> rootMessengerKey = GlobalKey<ScaffoldMessengerState>();
// Global Navigator key so dialogs can be shown from _MyAppState (which is above MaterialApp)
final GlobalKey<NavigatorState> rootNavigatorKey = GlobalKey<NavigatorState>();
// ── 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<String> _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<int>.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<String> _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<int>.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<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> with SingleTickerProviderStateMixin {
bool _isLoginInProgress = false;
List<FileItem> files = [];
List<String> folders = [];
// Local folders detected on disk (full paths)
List<String> localFolders = [];
bool loading = false;
String? error;
// Client login state
final TextEditingController _clientController = TextEditingController();
String _clientName = '';
String _authKey = '';
Map<String, dynamic>? _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<String, dynamic> _syncSettings = {};
final Map<String, Timer> _syncTimers = {};
final Map<String, bool> _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<String, Timer> _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<String, Completer<List<FileItem>>> _pendingListRequests = {};
final Map<String, Completer<String?>> _pendingPresign = {};
// Folder sync state
Directory? _currentProjectDir; // local root for extracted folder
String? _currentFolderPrefix; // e.g. 'projects/<id>/'
StreamSubscription? _watchSub; // legacy single-watcher (kept for compat)
final Map<String, StreamSubscription> _watchSubs = {}; // multi-folder watchers
final Map<String, DateTime> _lastUpload = {};
final Set<String> _knownS3Keys = {}; // populated from API for the folder
final Map<String, String> _lastUploadedHash = {}; // localPath -> sha256
final Map<String, bool> _folderDownloading = {}; // folder -> in-progress
final Map<String, DateTime> _lastFolderDownloadTs = {}; // folder -> last ts
final DatabaseHelper _db = DatabaseHelper();
// Sync log state
List<Map<String, dynamic>> _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<String, Map<String, dynamic>> _syncMeta = {};
// Changes state
List<ChangeItem> _changes = [];
List<FileItem> _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<File> _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<void> _loadSyncMeta() async {
try {
final f = await _syncMetaFile();
final content = await f.readAsString();
if (content.trim().isEmpty) return;
final Map<String, dynamic> data = jsonDecode(content);
data.forEach((k, v) {
if (v is Map) _syncMeta[k] = Map<String, dynamic>.from(v);
});
} catch (e) {
print('Failed to load sync meta: $e');
}
}
Future<void> _saveSyncMeta() async {
try {
final f = await _syncMetaFile();
await f.writeAsString(jsonEncode(_syncMeta));
} catch (e) {
print('Failed to save sync meta: $e');
}
}
void _detectRemoteMissingFiles(List<FileItem> 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<void> _checkForChanges() async {
if (_currentProjectDir == null || _currentFolderPrefix == null) return;
final localDir = _currentProjectDir!;
final prefix = _currentFolderPrefix!;
final newChanges = <ChangeItem>[];
final remoteMap = <String, FileItem>{};
// 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<void> _unlockStorage() async {
try {
await Process.run('chflags', ['nouchg', _managedStoragePath]);
} catch (_) {}
}
/// Re-apply hidden + user-immutable flags.
Future<void> _lockStorage() async {
try {
await Process.run('chflags', ['hidden', _managedStoragePath]);
await Process.run('chflags', ['uchg', _managedStoragePath]);
} catch (_) {}
}
Future<void> _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<void> _migrateOldStorage(String managedPath) async {
final home = Platform.environment['HOME'] ?? Directory.current.path;
final candidates = <String>[
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<void> _saveStoragePath(String path) async {
_storageBasePath = path;
_storageController.text = path;
}
Future<void> _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<bool> _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<void> _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<void> _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<void> _saveClientName(String name) async {
try {
await _secureStorage.write(key: 'planxo_client_name', value: name);
} catch (e) {
print('Failed to save client name: $e');
}
}
Future<void> _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<void> _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<void> _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<void> _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<int> _syncLocalChanges() async {
if (_currentProjectDir == null || _currentFolderPrefix == null) return 0;
int uploadCount = 0;
final localFiles = <String, File>{};
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<int> _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<void> _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<void> _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<String, dynamic> 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<String, dynamic> 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<void> _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<int>.generate(32, (_) => rng.nextInt(256));
return base64UrlEncode(bytes).replaceAll('=', '');
}
Future<void> _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<void> _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<String>(
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<void> _scanLocalClientFolders() async {
setState(() { loading = true; });
try {
final home = Platform.environment['HOME'] ?? '';
final candidates = <String>[];
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<String> 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<void> _scanStorageFolders() async {
if (_storageBasePath.trim().isEmpty) return;
final base = Directory(_storageBasePath);
if (!await base.exists()) return;
try {
final found = <String>{};
// Walk depth-1 and depth-2 to catch both flat and clients/<name>/<id> 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/<client>/<id>
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<void> _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<void> _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<String, dynamic> 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<FileItem> 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<FileItem> list) {
// find all pending list completers and complete them
if (_pendingListRequests.isEmpty) return;
final keys = List<String>.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<void> 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<List<FileItem>>();
_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<FileItem>? 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<String> 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<dynamic> 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<void> _fetchFileListHttpFallback() async {
setState(() { loading = true; error = null; });
try {
// 1. Try to fetch filtered folders from backend first
List<String> 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<dynamic> 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<FileItem> list = [];
final List<String> 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<File> _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<void> _loadSyncSettings() async {
try {
final f = await _settingsFile();
final content = await f.readAsString();
if (content.trim().isEmpty) return;
final Map<String, dynamic> 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<void> _saveSyncSettings() async {
try {
// persist prefs inside settings file
final prefs = (_syncSettings['_prefs'] is Map)
? Map<String, dynamic>.from(_syncSettings['_prefs'])
: <String, dynamic>{};
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<void> _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<String, dynamic>;
cfg['enabled'] = false;
_syncSettings[folder] = cfg;
_saveSyncSettings();
}
}
Future<void> _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<void> _setIntervalForFolder(String folder, int minutes) async {
final cfg = _syncSettings[folder] is Map ? Map<String, dynamic>.from(_syncSettings[folder]) : <String, dynamic>{};
cfg['interval'] = minutes;
_syncSettings[folder] = cfg;
_saveSyncSettings();
// restart timer if enabled
if (cfg['enabled'] == true) {
_startFolderPeriodicSync(folder, minutes);
}
setState(() {});
}
Future<void> _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<void>(context: rootNavigatorKey.currentContext!, builder: (ctx) {
return AlertDialog(
title: Text('Sync interval for $folder'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
RadioListTile<int>(value: 15, groupValue: selected, title: const Text('Every 15 minutes'), onChanged: (v) { if (v!=null) { selected = v; setState(() {}); } }),
RadioListTile<int>(value: 30, groupValue: selected, title: const Text('Every 30 minutes'), onChanged: (v) { if (v!=null) { selected = v; setState(() {}); } }),
RadioListTile<int>(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<String?> 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<String?>();
_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<int> _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<List<int>>(
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<int>);
// 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<void> 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<void> 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<void> _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<void> _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<void> _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<void> _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<String> _hashFile(String path) async {
final f = File(path);
final bytes = await f.readAsBytes();
final digest = crypto.sha256.convert(bytes);
return digest.toString();
}
Future<void> _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<void> _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<void> _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<void> _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<File?> 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<String> _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<String>(
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<String, dynamic> : 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<String, dynamic> : 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<String> 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 = <String>{};
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<String, dynamic> 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});
}