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'; import 'app_detail_screen.dart'; class AppsScreen extends HookWidget { final ApiService api; final VoidCallback onLogout; const AppsScreen({super.key, required this.api, required this.onLogout}); @override Widget build(BuildContext context) { final teams = useState>([]); final loading = useState(true); final error = useState(null); final installedVersions = useState>({}); final deviceService = useMemoized(() => DeviceService()); Future checkInstalledVersions(List apps) async { for (final app in apps) { final version = await deviceService.getInstalledVersion(app.packageName); final updated = Map.from(installedVersions.value); updated[app.packageName] = version; installedVersions.value = updated; } } Future loadApps() async { loading.value = true; error.value = null; try { final response = await api.getApps(); teams.value = response.teams; loading.value = false; checkInstalledVersions(response.allApps); } on AuthException { onLogout(); } on ApiException catch (e) { error.value = e.message; loading.value = false; } catch (_) { error.value = 'Failed to load apps'; loading.value = false; } } useEffect(() { loadApps(); return null; }, const []); void openApp(AppInfo app) { Navigator.of(context).push( MaterialPageRoute( builder: (_) => AppDetailScreen( api: api, app: app, installedVersion: installedVersions.value[app.packageName], ), ), ); } return Scaffold( appBar: AppBar( title: Text( 'UpdateForge', style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600), ), actions: [ IconButton( icon: const Icon(Icons.refresh_rounded, size: 22), onPressed: loading.value ? null : loadApps, ), IconButton( icon: const Icon(Icons.logout_rounded, size: 22), onPressed: onLogout, ), ], ), body: _buildBody( loading: loading.value, error: error.value, teams: teams.value, installedVersions: installedVersions.value, api: api, onRetry: loadApps, onOpenApp: openApp, ), ); } Widget _buildBody({ required bool loading, required String? error, required List teams, required Map installedVersions, required ApiService api, required Future Function() onRetry, required void Function(AppInfo) onOpenApp, }) { if (loading) { return const Center( child: CircularProgressIndicator(color: AppColors.accent), ); } if (error != null) { return Center( child: Padding( padding: const EdgeInsets.all(32), child: Column( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.error_outline, color: AppColors.danger, size: 48), const SizedBox(height: 16), Text(error, textAlign: TextAlign.center, style: const TextStyle(color: AppColors.muted)), const SizedBox(height: 20), ElevatedButton( onPressed: () => onRetry(), child: const Text('Retry'), ), ], ), ), ); } if (teams.isEmpty) { return Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.inbox_outlined, color: AppColors.muted.withValues(alpha: 0.4), size: 64), const SizedBox(height: 16), const Text('No apps assigned', style: TextStyle(color: AppColors.muted)), ], ), ); } return RefreshIndicator( color: AppColors.accent, onRefresh: onRetry, child: ListView.builder( padding: const EdgeInsets.symmetric(vertical: 12), itemCount: teams.length, itemBuilder: (context, teamIndex) { final team = teams[teamIndex]; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 6), child: Text( team.name, style: TextStyle( fontSize: 13, fontWeight: FontWeight.w600, color: AppColors.muted, ), ), ), ...team.apps.isEmpty ? [ Card( child: Padding( padding: const EdgeInsets.all(16), child: Center( child: Text( 'No apps in this team', style: TextStyle( color: AppColors.muted, fontSize: 13, ), ), ), ), ), ] : team.apps.map((app) => AppCard( app: app, installedVersion: installedVersions[app.packageName], iconUrl: api.iconUrl(app.icon), onTap: () => onOpenApp(app), )), ], ); }, ), ); } } class AppCard extends StatelessWidget { final AppInfo app; final String? installedVersion; final String iconUrl; final VoidCallback onTap; const AppCard({ super.key, required this.app, this.installedVersion, required this.iconUrl, required this.onTap, }); String get _latestVersion { final tracks = [app.release, app.debug, app.profile]; String? latest; for (final track in tracks) { if (track.current != null) { if (latest == null || _isNewer(track.current!.version, latest)) { latest = track.current!.version; } } } return latest ?? ''; } 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; } bool get _hasUpdate { if (installedVersion == null || _latestVersion.isEmpty) return false; return _isNewer(_latestVersion, installedVersion!); } bool get _isInstalled => installedVersion != null; String get _statusText { if (_hasUpdate) return 'Update available'; if (_isInstalled) return 'Installed'; return 'Not installed'; } IconData get _statusIcon { if (_hasUpdate) return Icons.arrow_upward_rounded; if (_isInstalled) return Icons.check_circle; return Icons.download_rounded; } Color get _statusColor { if (_hasUpdate) return AppColors.accent; if (_isInstalled) return AppColors.success; return AppColors.muted; } @override Widget build(BuildContext context) { return Card( child: InkWell( onTap: onTap, borderRadius: BorderRadius.circular(12), child: Padding( padding: const EdgeInsets.all(14), child: Row( children: [ ClipRRect( borderRadius: BorderRadius.circular(10), child: Image.network( iconUrl, width: 48, height: 48, fit: BoxFit.cover, errorBuilder: (_, _, _) => Container( width: 48, height: 48, decoration: BoxDecoration( color: AppColors.accentDim, borderRadius: BorderRadius.circular(10), ), child: const Icon(Icons.android, color: AppColors.accent, size: 24), ), ), ), const SizedBox(width: 14), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( app.title, style: const TextStyle( fontWeight: FontWeight.w600, fontSize: 15, color: AppColors.text, ), ), const SizedBox(height: 3), Text( app.packageName, style: const TextStyle( color: AppColors.muted, fontSize: 12, ), ), ], ), ), const SizedBox(width: 12), Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration( color: AppColors.elevated, borderRadius: BorderRadius.circular(6), border: Border.all(color: AppColors.border), ), child: Text( _latestVersion.isNotEmpty ? 'v$_latestVersion' : 'No version', style: const TextStyle( color: AppColors.text, fontSize: 11, fontWeight: FontWeight.w500, ), ), ), const SizedBox(height: 5), Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration( color: _statusColor.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(4), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(_statusIcon, size: 12, color: _statusColor), const SizedBox(width: 3), Text( _statusText, style: TextStyle( fontSize: 11, color: _statusColor, fontWeight: FontWeight.w600, ), ), ], ), ), ], ), ], ), ), ), ); } }