// main.dart - Main entry point for MangaShelf Flutter app import 'package:flutter/material.dart'; import 'package:http/http.dart' as http; import 'dart:convert'; import 'dart:io' show Platform, File; import 'dart:typed_data'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:file_picker/file_picker.dart'; import 'package:http_parser/http_parser.dart'; // IMPORTANT: Change this to your computer's IP address for testing on real device // For emulator, you can use 'http://10.0.2.2:3030' (Android) or 'http://localhost:3030' (iOS) const String SERVER_URL = 'http://localhost:3000'; void main() { runApp(MangaShelfApp()); } class MangaShelfApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( title: 'MangaShelf', theme: ThemeData( primarySwatch: Colors.purple, brightness: Brightness.dark, scaffoldBackgroundColor: Color(0xFF1a1a1a), ), home: SplashScreen(), ); } } // ==================== SPLASH SCREEN ==================== class SplashScreen extends StatefulWidget { @override _SplashScreenState createState() => _SplashScreenState(); } class _SplashScreenState extends State { @override void initState() { super.initState(); checkAuthStatus(); } Future checkAuthStatus() async { final prefs = await SharedPreferences.getInstance(); final token = prefs.getString('auth_token'); await Future.delayed(Duration(seconds: 2)); // Show splash for 2 seconds if (token != null) { Navigator.pushReplacement( context, MaterialPageRoute(builder: (context) => HomeScreen()), ); } else { Navigator.pushReplacement( context, MaterialPageRoute(builder: (context) => LoginScreen()), ); } } @override Widget build(BuildContext context) { return Scaffold( body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( Icons.menu_book, size: 100, color: Colors.purple, ), SizedBox(height: 20), Text( 'MangaShelf', style: TextStyle( fontSize: 32, fontWeight: FontWeight.bold, color: Colors.white, ), ), SizedBox(height: 20), CircularProgressIndicator(color: Colors.purple), ], ), ), ); } } // ==================== LOGIN SCREEN ==================== class LoginScreen extends StatefulWidget { @override _LoginScreenState createState() => _LoginScreenState(); } class _LoginScreenState extends State { final _formKey = GlobalKey(); final _usernameController = TextEditingController(); final _passwordController = TextEditingController(); bool _isLoading = false; bool _isRegisterMode = false; Future _handleSubmit() async { if (_formKey.currentState!.validate()) { setState(() => _isLoading = true); try { final endpoint = _isRegisterMode ? '/api/auth/register' : '/api/auth/login'; final response = await http.post( Uri.parse('$SERVER_URL$endpoint'), headers: {'Content-Type': 'application/json'}, body: json.encode({ 'username': _usernameController.text, 'password': _passwordController.text, }), ); final data = json.decode(response.body); if (response.statusCode == 200) { // Save token final prefs = await SharedPreferences.getInstance(); await prefs.setString('auth_token', data['token']); await prefs.setString('username', data['user']['username']); Navigator.pushReplacement( context, MaterialPageRoute(builder: (context) => HomeScreen()), ); } else { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(data['error'] ?? 'An error occurred')), ); } } catch (e) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Connection error. Check if server is running.')), ); } finally { setState(() => _isLoading = false); } } } @override Widget build(BuildContext context) { return Scaffold( body: SafeArea( child: Center( child: SingleChildScrollView( padding: EdgeInsets.all(24), child: Form( key: _formKey, child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( Icons.menu_book, size: 80, color: Colors.purple, ), SizedBox(height: 20), Text( _isRegisterMode ? 'Create Account' : 'Welcome Back', style: TextStyle( fontSize: 28, fontWeight: FontWeight.bold, ), ), SizedBox(height: 40), TextFormField( controller: _usernameController, decoration: InputDecoration( labelText: 'Username', prefixIcon: Icon(Icons.person), border: OutlineInputBorder( borderRadius: BorderRadius.circular(12), ), ), validator: (value) { if (value == null || value.isEmpty) { return 'Please enter username'; } return null; }, ), SizedBox(height: 20), TextFormField( controller: _passwordController, obscureText: true, decoration: InputDecoration( labelText: 'Password', prefixIcon: Icon(Icons.lock), border: OutlineInputBorder( borderRadius: BorderRadius.circular(12), ), ), validator: (value) { if (value == null || value.isEmpty) { return 'Please enter password'; } if (_isRegisterMode && value.length < 6) { return 'Password must be at least 6 characters'; } return null; }, ), SizedBox(height: 30), SizedBox( width: double.infinity, height: 50, child: ElevatedButton( onPressed: _isLoading ? null : _handleSubmit, style: ElevatedButton.styleFrom( backgroundColor: Colors.purple, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), ), child: _isLoading ? CircularProgressIndicator(color: Colors.white) : Text( _isRegisterMode ? 'Register' : 'Login', style: TextStyle(fontSize: 18), ), ), ), SizedBox(height: 20), TextButton( onPressed: () { setState(() => _isRegisterMode = !_isRegisterMode); }, child: Text( _isRegisterMode ? 'Already have an account? Login' : "Don't have an account? Register", ), ), ], ), ), ), ), ), ); } } // ==================== HOME SCREEN (LIBRARY) ==================== class HomeScreen extends StatefulWidget { @override _HomeScreenState createState() => _HomeScreenState(); } class _HomeScreenState extends State { String status = 'No file selected'; List _mangaList = []; bool _isLoading = true; @override void initState() { super.initState(); loadLibrary(); } Future loadLibrary() async { try { final prefs = await SharedPreferences.getInstance(); final token = prefs.getString('auth_token'); final response = await http.get( Uri.parse('$SERVER_URL/api/library'), headers: { 'Authorization': 'Bearer $token', }, ); if (response.statusCode == 200) { setState(() { _mangaList = json.decode(response.body); _isLoading = false; }); } else { throw Exception('Failed to load library'); } } catch (e) { setState(() => _isLoading = false); ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Error loading library')), ); } } Future _logout() async { final prefs = await SharedPreferences.getInstance(); await prefs.clear(); Navigator.pushReplacement( context, MaterialPageRoute(builder: (context) => LoginScreen()), ); } Future _uploadManga() async { FilePickerResult? result = await FilePicker.platform.pickFiles( type: FileType.custom, allowedExtensions: ['cbz', 'cbr', 'zip', 'rar'], ); if (result != null) { // For web, use bytes instead of path final bytes = result.files.single.bytes; final fileName = result.files.single.name; if (bytes != null) { showDialog( context: context, builder: (context) => UploadDialogWeb( fileBytes: bytes, fileName: fileName, ), ).then((_) { loadLibrary(); }); } else { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Error reading file')), ); } } else { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('No file selected')), ); } } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text('My Library'), backgroundColor: Color(0xFF2a2a2a), actions: [ IconButton( icon: Icon(Icons.add), onPressed: _uploadManga, ), IconButton( icon: Icon(Icons.refresh), onPressed: loadLibrary, ), IconButton( icon: Icon(Icons.logout), onPressed: _logout, ), ], ), body: _isLoading ? Center(child: CircularProgressIndicator()) : _mangaList.isEmpty ? Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(Icons.library_books, size: 64, color: Colors.grey), SizedBox(height: 16), Text( 'Your library is empty', style: TextStyle(fontSize: 18, color: Colors.grey), ), SizedBox(height: 8), Text( 'Upload your first manga/comic', style: TextStyle(color: Colors.grey[600]), ), SizedBox(height: 16), ElevatedButton( onPressed: _uploadManga, style: ElevatedButton.styleFrom( backgroundColor: Colors.purple, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8), ), ), child: Text('Add Comics'), ), ], ), ) : RefreshIndicator( onRefresh: loadLibrary, child: GridView.builder( padding: EdgeInsets.all(16), gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 2, childAspectRatio: 0.7, crossAxisSpacing: 16, mainAxisSpacing: 16, ), itemCount: _mangaList.length, itemBuilder: (context, index) { final manga = _mangaList[index]; return MangaCard( manga: manga, onTap: () { Navigator.push( context, MaterialPageRoute( builder: (context) => MangaDetailScreen( mangaId: manga['id'], title: manga['title'], ), ), ).then((_) => loadLibrary()); }, ); }, ), ), ); } } // ==================== UPLOAD DIALOG ==================== class UploadDialog extends StatefulWidget { final String filePath; final String fileName; UploadDialog({required this.filePath, required this.fileName}); @override _UploadDialogState createState() => _UploadDialogState(); } class _UploadDialogState extends State { final _formKey = GlobalKey(); final _titleController = TextEditingController(); final _authorController = TextEditingController(); final _descriptionController = TextEditingController(); bool _isUploading = false; @override void initState() { super.initState(); // Set default title from file name String fileNameWithoutExt = widget.fileName.split('.').first; _titleController.text = fileNameWithoutExt; } Future _uploadManga() async { if (_formKey.currentState!.validate()) { setState(() => _isUploading = true); try { final prefs = await SharedPreferences.getInstance(); final token = prefs.getString('auth_token'); // Create multipart request var request = http.MultipartRequest('POST', Uri.parse('$SERVER_URL/api/upload')); request.headers['Authorization'] = 'Bearer $token'; // Read the file and create multipart file var file = File(widget.filePath); var multipartFile = http.MultipartFile.fromBytes( 'comic', await file.readAsBytes(), filename: widget.fileName, contentType: MediaType('application', 'octet-stream'), ); request.files.add(multipartFile); // Add other fields request.fields['title'] = _titleController.text; request.fields['author'] = _authorController.text; request.fields['description'] = _descriptionController.text; var response = await request.send(); var responseBody = await response.stream.bytesToString(); var result = json.decode(responseBody); if (response.statusCode == 200) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Upload successful!')), ); Navigator.of(context).pop(); // Close the dialog // Refresh the library screen by going back if (Navigator.of(context).canPop()) { Navigator.of(context).pop(); // Go back to library } } else { throw Exception(result['error'] ?? 'Upload failed'); } } catch (e) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Upload failed: $e')), ); } finally { setState(() => _isUploading = false); } } } @override Widget build(BuildContext context) { return AlertDialog( title: Text('Upload Comic'), content: Container( width: double.maxFinite, child: Form( key: _formKey, child: Column( mainAxisSize: MainAxisSize.min, children: [ TextFormField( controller: _titleController, decoration: InputDecoration( labelText: 'Title', border: OutlineInputBorder( borderRadius: BorderRadius.circular(8), ), ), validator: (value) { if (value == null || value.isEmpty) { return 'Please enter a title'; } return null; }, ), SizedBox(height: 12), TextFormField( controller: _authorController, decoration: InputDecoration( labelText: 'Author', border: OutlineInputBorder( borderRadius: BorderRadius.circular(8), ), ), ), SizedBox(height: 12), TextFormField( controller: _descriptionController, maxLines: 3, decoration: InputDecoration( labelText: 'Description', alignLabelWithHint: true, border: OutlineInputBorder( borderRadius: BorderRadius.circular(8), ), ), ), SizedBox(height: 16), Text( 'File: ${widget.fileName}', style: TextStyle(fontSize: 12, color: Colors.grey), ), ], ), ), ), actions: [ TextButton( onPressed: () { Navigator.of(context).pop(); }, child: Text('Cancel'), ), ElevatedButton( onPressed: _isUploading ? null : _uploadManga, style: ElevatedButton.styleFrom( backgroundColor: Colors.purple, ), child: _isUploading ? SizedBox( width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, valueColor: AlwaysStoppedAnimation(Colors.white)), ) : Text('Upload'), ), ], ); } } // ==================== UPLOAD DIALOG FOR WEB ==================== class UploadDialogWeb extends StatefulWidget { final Uint8List fileBytes; final String fileName; UploadDialogWeb({required this.fileBytes, required this.fileName}); @override _UploadDialogWebState createState() => _UploadDialogWebState(); } class _UploadDialogWebState extends State { final _formKey = GlobalKey(); final _titleController = TextEditingController(); final _authorController = TextEditingController(); final _descriptionController = TextEditingController(); bool _isUploading = false; @override void initState() { super.initState(); String fileNameWithoutExt = widget.fileName.split('.').first; _titleController.text = fileNameWithoutExt; } Future _uploadManga() async { if (_formKey.currentState!.validate()) { setState(() => _isUploading = true); try { final prefs = await SharedPreferences.getInstance(); final token = prefs.getString('auth_token'); var request = http.MultipartRequest('POST', Uri.parse('$SERVER_URL/api/upload')); request.headers['Authorization'] = 'Bearer $token'; var multipartFile = http.MultipartFile.fromBytes( 'comic', widget.fileBytes, filename: widget.fileName, contentType: MediaType('application', 'octet-stream'), ); request.files.add(multipartFile); request.fields['title'] = _titleController.text; request.fields['author'] = _authorController.text; request.fields['description'] = _descriptionController.text; var response = await request.send(); var responseBody = await response.stream.bytesToString(); var result = json.decode(responseBody); if (response.statusCode == 200) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Upload successful!')), ); Navigator.of(context).pop(); } else { throw Exception(result['error'] ?? 'Upload failed'); } } catch (e) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Upload failed: $e')), ); } finally { setState(() => _isUploading = false); } } } @override Widget build(BuildContext context) { return AlertDialog( title: Text('Upload Comic'), content: Container( width: double.maxFinite, child: Form( key: _formKey, child: Column( mainAxisSize: MainAxisSize.min, children: [ TextFormField( controller: _titleController, decoration: InputDecoration( labelText: 'Title', border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), ), validator: (value) => value?.isEmpty ?? true ? 'Please enter a title' : null, ), SizedBox(height: 12), TextFormField( controller: _authorController, decoration: InputDecoration( labelText: 'Author', border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), ), ), SizedBox(height: 12), TextFormField( controller: _descriptionController, maxLines: 3, decoration: InputDecoration( labelText: 'Description', alignLabelWithHint: true, border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), ), ), SizedBox(height: 16), Text('File: ${widget.fileName}', style: TextStyle(fontSize: 12, color: Colors.grey)), ], ), ), ), actions: [ TextButton(onPressed: () => Navigator.of(context).pop(), child: Text('Cancel')), ElevatedButton( onPressed: _isUploading ? null : _uploadManga, style: ElevatedButton.styleFrom(backgroundColor: Colors.purple), child: _isUploading ? SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, valueColor: AlwaysStoppedAnimation(Colors.white))) : Text('Upload'), ), ], ); } } // ==================== MANGA CARD WIDGET ==================== class MangaCard extends StatelessWidget { final Map manga; final VoidCallback onTap; MangaCard({required this.manga, required this.onTap}); @override Widget build(BuildContext context) { return GestureDetector( onTap: onTap, child: Container( decoration: BoxDecoration( color: Color(0xFF2a2a2a), borderRadius: BorderRadius.circular(12), boxShadow: [ BoxShadow( color: Colors.black26, blurRadius: 4, offset: Offset(0, 2), ), ], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: ClipRRect( borderRadius: BorderRadius.vertical(top: Radius.circular(12)), child: manga['cover_image'] != null ? Image.network( '$SERVER_URL${manga['cover_image']}', fit: BoxFit.cover, width: double.infinity, errorBuilder: (context, error, stackTrace) { return Container( color: Colors.purple[900], child: Icon(Icons.book, size: 50, color: Colors.white54), ); }, ) : Container( color: Colors.purple[900], child: Icon(Icons.book, size: 50, color: Colors.white54), ), ), ), Padding( padding: EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( manga['title'] ?? 'Unknown', style: TextStyle( fontWeight: FontWeight.bold, fontSize: 14, ), maxLines: 2, overflow: TextOverflow.ellipsis, ), if (manga['author'] != null) ...[ SizedBox(height: 4), Text( manga['author'], style: TextStyle( color: Colors.grey, fontSize: 12, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), ], ], ), ), ], ), ), ); } } // ==================== MANGA DETAIL SCREEN ==================== class MangaDetailScreen extends StatefulWidget { final int mangaId; final String title; MangaDetailScreen({required this.mangaId, required this.title}); @override _MangaDetailScreenState createState() => _MangaDetailScreenState(); } class _MangaDetailScreenState extends State { Map? _mangaDetail; bool _isLoading = true; @override void initState() { super.initState(); loadMangaDetail(); } Future loadMangaDetail() async { try { final prefs = await SharedPreferences.getInstance(); final token = prefs.getString('auth_token'); final response = await http.get( Uri.parse('$SERVER_URL/api/manga/${widget.mangaId}'), headers: { 'Authorization': 'Bearer $token', }, ); if (response.statusCode == 200) { setState(() { _mangaDetail = json.decode(response.body); _isLoading = false; }); } } catch (e) { setState(() => _isLoading = false); ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Error loading manga details')), ); } } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text(widget.title), backgroundColor: Color(0xFF2a2a2a), ), body: _isLoading ? Center(child: CircularProgressIndicator()) : SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Cover and info Container( height: 250, color: Color(0xFF2a2a2a), padding: EdgeInsets.all(16), child: Row( children: [ // Cover image Container( width: 150, height: 220, decoration: BoxDecoration( borderRadius: BorderRadius.circular(8), color: Colors.purple[900], ), child: _mangaDetail!['cover_image'] != null ? ClipRRect( borderRadius: BorderRadius.circular(8), child: Image.network( '$SERVER_URL${_mangaDetail!['cover_image']}', fit: BoxFit.cover, errorBuilder: (context, error, stackTrace) { return Icon(Icons.book, size: 50, color: Colors.white54); }, ), ) : Icon(Icons.book, size: 50, color: Colors.white54), ), SizedBox(width: 16), // Info Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.center, children: [ Text( _mangaDetail!['title'], style: TextStyle( fontSize: 20, fontWeight: FontWeight.bold, ), maxLines: 2, overflow: TextOverflow.ellipsis, ), if (_mangaDetail!['author'] != null) ...[ SizedBox(height: 8), Text( 'By ${_mangaDetail!['author']}', style: TextStyle(color: Colors.grey), ), ], SizedBox(height: 12), Text( '${_mangaDetail!['chapters']?.length ?? 0} Chapters', style: TextStyle(color: Colors.purple[300]), ), if (_mangaDetail!['progress'] != null) ...[ SizedBox(height: 8), Container( padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( color: Colors.green[800], borderRadius: BorderRadius.circular(4), ), child: Text( 'Reading: Ch ${_mangaDetail!['progress']['chapter_id']}', style: TextStyle(fontSize: 12), ), ), ], ], ), ), ], ), ), // Description if (_mangaDetail!['description'] != null) Padding( padding: EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Description', style: TextStyle( fontSize: 18, fontWeight: FontWeight.bold, ), ), SizedBox(height: 8), Text(_mangaDetail!['description']), ], ), ), // Chapters list Padding( padding: EdgeInsets.all(16), child: Text( 'Chapters', style: TextStyle( fontSize: 18, fontWeight: FontWeight.bold, ), ), ), ListView.builder( shrinkWrap: true, physics: NeverScrollableScrollPhysics(), itemCount: _mangaDetail!['chapters']?.length ?? 0, itemBuilder: (context, index) { final chapter = _mangaDetail!['chapters'][index]; return ListTile( title: Text(chapter['title'] ?? 'Chapter ${chapter['chapter_number']}'), subtitle: Text('Chapter ${chapter['chapter_number']}'), trailing: Icon(Icons.arrow_forward_ios, size: 16), onTap: () { Navigator.push( context, MaterialPageRoute( builder: (context) => ReaderScreen( chapterId: chapter['id'], mangaId: widget.mangaId, chapterTitle: chapter['title'] ?? 'Chapter ${chapter['chapter_number']}', ), ), ); }, ); }, ), ], ), ), ); } } // ==================== READER SCREEN ==================== class ReaderScreen extends StatefulWidget { final int chapterId; final int mangaId; final String chapterTitle; ReaderScreen({ required this.chapterId, required this.mangaId, required this.chapterTitle, }); @override _ReaderScreenState createState() => _ReaderScreenState(); } class _ReaderScreenState extends State { List _pages = []; int _currentPage = 0; bool _isLoading = true; PageController _pageController = PageController(); @override void initState() { super.initState(); loadPages(); } Future loadPages() async { try { final prefs = await SharedPreferences.getInstance(); final token = prefs.getString('auth_token'); final response = await http.get( Uri.parse('$SERVER_URL/api/chapters/${widget.chapterId}/pages'), headers: { 'Authorization': 'Bearer $token', }, ); if (response.statusCode == 200) { final data = json.decode(response.body); setState(() { _pages = data['pages']; _isLoading = false; }); } } catch (e) { setState(() => _isLoading = false); ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Error loading pages')), ); } } Future updateProgress(int page) async { try { final prefs = await SharedPreferences.getInstance(); final token = prefs.getString('auth_token'); await http.post( Uri.parse('$SERVER_URL/api/progress'), headers: { 'Authorization': 'Bearer $token', 'Content-Type': 'application/json', }, body: json.encode({ 'manga_id': widget.mangaId, 'chapter_id': widget.chapterId, 'page_number': page + 1, }), ); } catch (e) { // Silently fail - don't interrupt reading } } @override Widget build(BuildContext context) { return Scaffold( backgroundColor: Colors.black, appBar: AppBar( backgroundColor: Colors.black, title: Text(widget.chapterTitle), actions: [ if (!_isLoading) Padding( padding: EdgeInsets.only(right: 16), child: Center( child: Text( '${_currentPage + 1} / ${_pages.length}', style: TextStyle(fontSize: 16), ), ), ), ], ), body: _isLoading ? Center(child: CircularProgressIndicator()) : PageView.builder( controller: _pageController, onPageChanged: (index) { setState(() => _currentPage = index); updateProgress(index); }, itemCount: _pages.length, itemBuilder: (context, index) { return InteractiveViewer( minScale: 1.0, maxScale: 4.0, child: Center( child: Image.network( '$SERVER_URL${_pages[index]['url']}', fit: BoxFit.contain, loadingBuilder: (context, child, loadingProgress) { if (loadingProgress == null) return child; return Center( child: CircularProgressIndicator( value: loadingProgress.expectedTotalBytes != null ? loadingProgress.cumulativeBytesLoaded / loadingProgress.expectedTotalBytes! : null, ), ); }, errorBuilder: (context, error, stackTrace) { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(Icons.error, color: Colors.red, size: 48), SizedBox(height: 16), Text('Failed to load page'), ], ), ); }, ), ), ); }, ), ); } }