diff --git a/lib/config/theme.dart b/lib/config/theme.dart new file mode 100644 index 0000000..16afe20 --- /dev/null +++ b/lib/config/theme.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; + +class AppColors { + static const bg = Color(0xFF0d0e11); + static const surface = Color(0xFF161820); + static const elevated = Color(0xFF1e2028); + static const border = Color(0xFF2a2d38); + static const accent = Color(0xFFf97316); + static const accentDim = Color(0xFF7c3a10); + static const text = Color(0xFFe2e8f0); + static const muted = Color(0xFF64748b); + static const danger = Color(0xFFef4444); + static const success = Color(0xFF22c55e); +} + +ThemeData buildAppTheme() { + return ThemeData( + brightness: Brightness.dark, + scaffoldBackgroundColor: AppColors.bg, + colorScheme: const ColorScheme.dark( + primary: AppColors.accent, + surface: AppColors.surface, + error: AppColors.danger, + ), + appBarTheme: const AppBarTheme( + backgroundColor: AppColors.surface, + foregroundColor: AppColors.text, + elevation: 0, + surfaceTintColor: Colors.transparent, + ), + cardTheme: CardThemeData( + color: AppColors.surface, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: const BorderSide(color: AppColors.border), + ), + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: AppColors.elevated, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: const BorderSide(color: AppColors.border), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: const BorderSide(color: AppColors.border), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: const BorderSide(color: AppColors.accent, width: 1.5), + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + hintStyle: const TextStyle(color: AppColors.muted), + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.accent, + foregroundColor: Colors.white, + minimumSize: const Size(double.infinity, 48), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + textStyle: const TextStyle(fontWeight: FontWeight.w600, fontSize: 15), + ), + ), + textButtonTheme: TextButtonThemeData( + style: TextButton.styleFrom( + foregroundColor: AppColors.accent, + ), + ), + dividerTheme: const DividerThemeData( + color: AppColors.border, + thickness: 1, + ), + snackBarTheme: SnackBarThemeData( + backgroundColor: AppColors.elevated, + contentTextStyle: const TextStyle(color: AppColors.text), + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + ); +} diff --git a/lib/screens/app_detail_screen.dart b/lib/screens/app_detail_screen.dart new file mode 100644 index 0000000..a85c385 --- /dev/null +++ b/lib/screens/app_detail_screen.dart @@ -0,0 +1,548 @@ +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, + }); +} diff --git a/lib/screens/apps_screen.dart b/lib/screens/apps_screen.dart new file mode 100644 index 0000000..272cc73 --- /dev/null +++ b/lib/screens/apps_screen.dart @@ -0,0 +1,374 @@ +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, + ), + ), + ], + ), + ), + ], + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/screens/login_screen.dart b/lib/screens/login_screen.dart new file mode 100644 index 0000000..29deb8e --- /dev/null +++ b/lib/screens/login_screen.dart @@ -0,0 +1,171 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import '../config/theme.dart'; +import '../services/api_service.dart'; + +class LoginScreen extends HookWidget { + final ApiService api; + final VoidCallback onLogin; + + const LoginScreen({super.key, required this.api, required this.onLogin}); + + @override + Widget build(BuildContext context) { + final emailController = useTextEditingController(); + final passwordController = useTextEditingController(); + final serverController = useTextEditingController(text: api.baseUrl); + final formKey = useMemoized(() => GlobalKey()); + final loading = useState(false); + final error = useState(null); + + Future login() async { + if (!formKey.currentState!.validate()) return; + + loading.value = true; + error.value = null; + + try { + api.baseUrl = serverController.text.trim(); + await api.login( + emailController.text.trim(), + passwordController.text, + ); + onLogin(); + } on ApiException catch (e) { + error.value = e.message; + } catch (_) { + error.value = 'Connection failed'; + } finally { + loading.value = false; + } + } + + return Scaffold( + body: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(32), + child: Form( + key: formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 72, + height: 72, + decoration: BoxDecoration( + color: AppColors.accent.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(20), + ), + child: Image.asset( + 'assets/images/app_icon.png', + width: 36, + height: 36, + fit: BoxFit.contain, + ), + ), + const SizedBox(height: 24), + const Text( + 'UpdateForge', + style: TextStyle( + fontSize: 28, + fontWeight: FontWeight.bold, + color: AppColors.text, + ), + ), + const SizedBox(height: 6), + Text( + 'Sign in to access your apps', + style: TextStyle(color: AppColors.muted, fontSize: 14), + ), + const SizedBox(height: 40), + TextFormField( + controller: serverController, + decoration: const InputDecoration( + labelText: 'Server URL', + hintText: 'https://example.com', + prefixIcon: Icon(Icons.dns_outlined, size: 20), + ), + keyboardType: TextInputType.url, + validator: (v) { + if (v == null || v.trim().isEmpty) return 'Required'; + final url = Uri.tryParse(v.trim()); + if (url == null || !url.hasScheme) return 'Invalid URL'; + return null; + }, + ), + const SizedBox(height: 14), + TextFormField( + controller: emailController, + decoration: const InputDecoration( + labelText: 'Email', + prefixIcon: Icon(Icons.mail_outline, size: 20), + ), + keyboardType: TextInputType.emailAddress, + validator: (v) => + v == null || v.trim().isEmpty ? 'Required' : null, + ), + const SizedBox(height: 14), + TextFormField( + controller: passwordController, + decoration: const InputDecoration( + labelText: 'Password', + prefixIcon: Icon(Icons.lock_outline, size: 20), + ), + obscureText: true, + onFieldSubmitted: (_) => login(), + validator: (v) => + v == null || v.isEmpty ? 'Required' : null, + ), + if (error.value != null) ...[ + const SizedBox(height: 14), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.danger.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: AppColors.danger.withValues(alpha: 0.3), + ), + ), + child: Row( + children: [ + const Icon(Icons.error_outline, + color: AppColors.danger, size: 18), + const SizedBox(width: 10), + Expanded( + child: Text( + error.value!, + style: const TextStyle( + color: AppColors.danger, + fontSize: 13, + ), + ), + ), + ], + ), + ), + ], + const SizedBox(height: 28), + ElevatedButton( + onPressed: loading.value ? null : login, + child: loading.value + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Text('Sign In'), + ), + ], + ), + ), + ), + ), + ), + ); + } +}