import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import '../config/theme.dart'; import '../models/models.dart'; import '../services/api_service.dart'; import '../services/device_service.dart'; class AppDetailScreen extends HookWidget { final ApiService api; final AppInfo app; final String? installedVersion; const AppDetailScreen({ super.key, required this.api, required this.app, this.installedVersion, }); @override Widget build(BuildContext context) { final deviceService = useMemoized(() => DeviceService()); final installedVersionState = useState(installedVersion); final downloadStates = useState>({}); Future refreshInstalledVersion() async { final version = await deviceService.getInstalledVersion(app.packageName); installedVersionState.value = version; } useEffect(() { refreshInstalledVersion(); return null; }, const []); Future downloadVersion(String stateKey, VersionInfo version) async { downloadStates.value = { ...downloadStates.value, stateKey: DownloadState(isDownloading: true, progress: 0), }; try { final response = await api.downloadVersion(version.id); final filePath = await deviceService.downloadFile( stream: response.stream, contentLength: response.contentLength, fileName: '${app.packageName}_${version.version}.apk', onProgress: (received, total) { if (total != null && total > 0) { downloadStates.value = { ...downloadStates.value, stateKey: DownloadState( isDownloading: true, progress: received / total, ), }; } }, ); downloadStates.value = { ...downloadStates.value, stateKey: DownloadState( isDownloading: false, downloadedPath: filePath, ), }; } catch (e) { downloadStates.value = { ...downloadStates.value, stateKey: DownloadState(), }; if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Download failed')), ); } } } Future installApk(String stateKey) async { final path = downloadStates.value[stateKey]?.downloadedPath; if (path == null) return; final success = await deviceService.installApk(path); if (!context.mounted) return; if (success) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Installation started')), ); Future.delayed(const Duration(seconds: 3), refreshInstalledVersion); } else { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Installation failed')), ); } } Future openApp() async { final success = await deviceService.openApp(app.packageName); if (!context.mounted) return; if (!success) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Could not open app')), ); } } return Scaffold( appBar: AppBar( title: Text(app.title), actions: [ IconButton( icon: const Icon(Icons.refresh_rounded, size: 22), onPressed: refreshInstalledVersion, ), ], ), body: ListView( padding: const EdgeInsets.all(16), children: [ _buildHeader(installedVersionState.value), const SizedBox(height: 24), _buildTrack( 'Release', app.release, 'release', installedVersion: installedVersionState.value, downloadStates: downloadStates, onDownload: downloadVersion, onInstall: installApk, onOpen: openApp, ), const SizedBox(height: 20), _buildTrack( 'Debug', app.debug, 'debug', installedVersion: installedVersionState.value, downloadStates: downloadStates, onDownload: downloadVersion, onInstall: installApk, onOpen: openApp, ), const SizedBox(height: 20), _buildTrack( 'Profile', app.profile, 'profile', installedVersion: installedVersionState.value, downloadStates: downloadStates, onDownload: downloadVersion, onInstall: installApk, onOpen: openApp, ), ], ), ); } Widget _buildHeader(String? installedVersion) { return Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: AppColors.surface, borderRadius: BorderRadius.circular(12), border: Border.all(color: AppColors.border), ), child: Row( children: [ ClipRRect( borderRadius: BorderRadius.circular(12), child: Image.network( api.iconUrl(app.icon), width: 56, height: 56, fit: BoxFit.cover, errorBuilder: (_, _, _) => Container( width: 56, height: 56, decoration: BoxDecoration( color: AppColors.accentDim, borderRadius: BorderRadius.circular(12), ), child: const Icon(Icons.android, color: AppColors.accent, size: 28), ), ), ), const SizedBox(width: 16), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( app.title, style: const TextStyle( fontWeight: FontWeight.w600, fontSize: 17, color: AppColors.text, ), ), const SizedBox(height: 2), Text( app.packageName, style: const TextStyle( color: AppColors.muted, fontSize: 12, ), ), if (installedVersion != null) ...[ const SizedBox(height: 6), Row( children: [ const Icon(Icons.check_circle, size: 14, color: AppColors.success), const SizedBox(width: 4), Text( 'Installed v$installedVersion', style: const TextStyle( color: AppColors.success, fontSize: 12, fontWeight: FontWeight.w500, ), ), ], ), ], ], ), ), ], ), ); } Widget _buildTrack( String label, Track track, String trackName, { required String? installedVersion, required ValueNotifier> downloadStates, required Future Function(String, VersionInfo) onDownload, required Future Function(String) onInstall, required Future Function() onOpen, }) { return Container( decoration: BoxDecoration( color: AppColors.surface, borderRadius: BorderRadius.circular(12), border: Border.all(color: AppColors.border), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.fromLTRB(16, 14, 16, 0), child: Row( children: [ Icon(_trackIcon(trackName), size: 16, color: _trackColor(trackName)), const SizedBox(width: 8), Text( label, style: TextStyle( fontWeight: FontWeight.w600, fontSize: 14, color: _trackColor(trackName), ), ), ], ), ), if (track.current != null) _buildVersionRow( track.current!, trackName, isCurrent: true, installedVersion: installedVersion, downloadStates: downloadStates, onDownload: onDownload, onInstall: onInstall, onOpen: onOpen, ) else _buildEmptyRow('No version'), if (track.pending != null) ...[ const Divider(height: 1, indent: 16, endIndent: 16), _buildVersionRow( track.pending!, trackName, isCurrent: false, installedVersion: installedVersion, downloadStates: downloadStates, onDownload: onDownload, onInstall: onInstall, onOpen: onOpen, ), ], if (track.current == null && track.pending == null) const Padding( padding: EdgeInsets.all(16), child: Text( 'No versions yet', style: TextStyle(color: AppColors.muted, fontSize: 13), ), ), ], ), ); } Widget _buildVersionRow( VersionInfo version, String trackName, { required bool isCurrent, required String? installedVersion, required ValueNotifier> downloadStates, required Future Function(String, VersionInfo) onDownload, required Future Function(String) onInstall, required Future Function() onOpen, }) { final stateKey = '${trackName}_${version.id}'; final state = downloadStates.value[stateKey]; final isDownloading = state?.isDownloading ?? false; final downloadProgress = state?.progress; final isNewer = installedVersion != null && _isNewer(version.version, installedVersion); final isInstalledSame = installedVersion == version.version && isCurrent; return Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration( color: isCurrent ? AppColors.accent.withValues(alpha: 0.12) : AppColors.muted.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(4), ), child: Text( isCurrent ? 'CURRENT' : 'PENDING', style: TextStyle( fontSize: 9, fontWeight: FontWeight.w700, color: isCurrent ? AppColors.accent : AppColors.muted, letterSpacing: 0.5, ), ), ), const SizedBox(width: 8), Expanded( child: Text( 'v${version.version}', style: const TextStyle( fontWeight: FontWeight.w600, fontSize: 15, color: AppColors.text, ), ), ), if (version.hasFile && isCurrent && !isInstalledSame) _buildDownloadButton(stateKey, version, downloadStates, onDownload, onInstall, onOpen), ], ), if (version.name.isNotEmpty) ...[ const SizedBox(height: 4), Text( version.name, style: const TextStyle(color: AppColors.muted, fontSize: 12), ), ], const SizedBox(height: 4), Row( children: [ Text( _formatDate(version.created), style: const TextStyle(color: AppColors.muted, fontSize: 11), ), if (version.public) ...[ const SizedBox(width: 8), const Icon(Icons.public, size: 12, color: AppColors.muted), ], if (isNewer) ...[ const SizedBox(width: 8), Container( padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1), decoration: BoxDecoration( color: AppColors.accent.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(4), ), child: const Text( 'UPDATE', style: TextStyle( fontSize: 9, fontWeight: FontWeight.w700, color: AppColors.accent, letterSpacing: 0.5, ), ), ), ], if (isInstalledSame) ...[ const SizedBox(width: 8), const Icon(Icons.check_circle, size: 13, color: AppColors.success), ], ], ), if (isDownloading && downloadProgress != null) ...[ const SizedBox(height: 8), LinearProgressIndicator( value: downloadProgress, backgroundColor: AppColors.elevated, color: AppColors.accent, minHeight: 3, borderRadius: BorderRadius.circular(2), ), const SizedBox(height: 4), Text( '${(downloadProgress * 100).toInt()}%', style: const TextStyle(color: AppColors.muted, fontSize: 11), ), ], ], ), ); } Widget _buildDownloadButton( String stateKey, VersionInfo version, ValueNotifier> downloadStates, Future Function(String, VersionInfo) onDownload, Future Function(String) onInstall, Future Function() onOpen, ) { final state = downloadStates.value[stateKey]; if (state?.downloadedPath != null) { return Row( mainAxisSize: MainAxisSize.min, children: [ TextButton.icon( onPressed: onOpen, icon: const Icon(Icons.open_in_new, size: 16), label: const Text('Open'), style: TextButton.styleFrom( foregroundColor: AppColors.success, padding: const EdgeInsets.symmetric(horizontal: 10), minimumSize: Size.zero, tapTargetSize: MaterialTapTargetSize.shrinkWrap, ), ), const SizedBox(width: 4), TextButton.icon( onPressed: () => onInstall(stateKey), icon: const Icon(Icons.install_mobile, size: 16), label: const Text('Install'), style: TextButton.styleFrom( foregroundColor: AppColors.accent, padding: const EdgeInsets.symmetric(horizontal: 10), minimumSize: Size.zero, tapTargetSize: MaterialTapTargetSize.shrinkWrap, ), ), ], ); } return IconButton( onPressed: (state?.isDownloading ?? false) ? null : () => onDownload(stateKey, version), icon: (state?.isDownloading ?? false) ? const SizedBox( width: 18, height: 18, child: CircularProgressIndicator( strokeWidth: 2, color: AppColors.accent, ), ) : const Icon(Icons.download_rounded, size: 20), color: AppColors.accent, tooltip: 'Download', padding: const EdgeInsets.all(8), constraints: const BoxConstraints(), ); } Widget _buildEmptyRow(String text) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Text( text, style: const TextStyle(color: AppColors.muted, fontSize: 13), ), ); } bool _isNewer(String a, String b) { final aParts = a.split('.').map((s) => int.tryParse(s.split('-').first) ?? 0).toList(); final bParts = b.split('.').map((s) => int.tryParse(s.split('-').first) ?? 0).toList(); for (var i = 0; i < (aParts.length > bParts.length ? aParts.length : bParts.length); i++) { final av = i < aParts.length ? aParts[i] : 0; final bv = i < bParts.length ? bParts[i] : 0; if (av > bv) return true; if (av < bv) return false; } return false; } IconData _trackIcon(String track) { switch (track) { case 'release': return Icons.rocket_launch; case 'debug': return Icons.bug_report; case 'profile': return Icons.speed; default: return Icons.code; } } Color _trackColor(String track) { switch (track) { case 'release': return AppColors.success; case 'debug': return AppColors.danger; case 'profile': return AppColors.accent; default: return AppColors.muted; } } String _formatDate(DateTime date) { return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; } } class DownloadState { final bool isDownloading; final double? progress; final String? downloadedPath; DownloadState({ this.isDownloading = false, this.progress, this.downloadedPath, }); }