flutter-update-forge-companion/lib/screens/apps_screen.dart

374 lines
12 KiB
Dart

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<List<TeamWithApps>>([]);
final loading = useState(true);
final error = useState<String?>(null);
final installedVersions = useState<Map<String, String?>>({});
final deviceService = useMemoized(() => DeviceService());
Future<void> checkInstalledVersions(List<AppInfo> apps) async {
for (final app in apps) {
final version = await deviceService.getInstalledVersion(app.packageName);
final updated = Map<String, String?>.from(installedVersions.value);
updated[app.packageName] = version;
installedVersions.value = updated;
}
}
Future<void> 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<TeamWithApps> teams,
required Map<String, String?> installedVersions,
required ApiService api,
required Future<void> 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,
),
),
],
),
),
],
),
],
),
),
),
);
}
}