4268 lines
167 KiB
Dart
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});
|
|
}
|
|
|