import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import '../emulator/emulator_screen.dart'; import 'game_library.dart'; /// The list of the user's games, with buttons to add one and to manage /// the optional BIOS. class HomeScreen extends StatefulWidget { const HomeScreen({super.key, required this.library, this.pickFile}); final GameLibrary library; /// Lets the user choose a file; returns its path and name, or null if /// cancelled. Defaults to the system file picker. final Future<({String path, String name})?> Function()? pickFile; @override State createState() => _HomeScreenState(); } class _HomeScreenState extends State { late Future> _games = widget.library.list(); bool _busy = false; void _reload() { final games = widget.library.list(); setState(() { _games = games; }); } Future<({String path, String name})?> _pick() async { final custom = widget.pickFile; if (custom != null) return custom(); // Any file type: Android has no MIME type for .gba, so an extension // filter would grey out the user's ROMs. The contents are checked // after picking instead. final result = await FilePicker.pickFiles(); final file = result?.files.singleOrNull; final path = file?.path; if (file == null || path == null) return null; return (path: path, name: file.name); } Future _addGame() async { final picked = await _pick(); if (picked == null || !mounted) return; setState(() => _busy = true); try { final game = await widget.library.importRom(picked.path, picked.name); _reload(); _show('Added ${game.title}.'); } on LibraryException catch (error) { _show(error.message); } finally { if (mounted) setState(() => _busy = false); } } Future _importBios() async { final picked = await _pick(); if (picked == null || !mounted) return; try { await widget.library.importBios(picked.path); setState(() {}); _show('BIOS imported. Games will use it from now on.'); } on LibraryException catch (error) { _show(error.message); } } Future _removeBios() async { await widget.library.removeBios(); setState(() {}); _show('Using the built-in BIOS replacement.'); } Future _play(GameEntry game) async { await Navigator.of(context).push( MaterialPageRoute( builder: (_) => EmulatorScreen( romPath: game.romPath, savePath: widget.library.savePathFor(game), biosPath: widget.library.biosPath, ), ), ); _reload(); // A save may have appeared. } Future _delete(GameEntry game) async { var deleteSave = false; final confirmed = await showDialog( context: context, builder: (context) => StatefulBuilder( builder: (context, setDialogState) => AlertDialog( title: Text('Remove ${game.title}?'), content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text('The copy of the ROM in this app will be deleted.'), if (game.hasSave) CheckboxListTile( contentPadding: EdgeInsets.zero, value: deleteSave, onChanged: (value) => setDialogState(() => deleteSave = value ?? false), title: const Text('Also delete the save'), ), ], ), actions: [ TextButton( onPressed: () => Navigator.pop(context, false), child: const Text('Cancel'), ), FilledButton( onPressed: () => Navigator.pop(context, true), child: const Text('Remove'), ), ], ), ), ); if (confirmed != true) return; await widget.library.delete(game, deleteSave: deleteSave); _reload(); } void _show(String message) { if (!mounted) return; ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(message))); } @override Widget build(BuildContext context) { final hasBios = widget.library.biosPath != null; return Scaffold( appBar: AppBar( title: const Text('SplashyGBA'), actions: [ PopupMenuButton( onSelected: (choice) => switch (choice) { 'bios' => _importBios(), 'no-bios' => _removeBios(), _ => _showAbout(), }, itemBuilder: (context) => [ if (hasBios) const PopupMenuItem( value: 'no-bios', child: Text('Use built-in BIOS replacement'), ) else const PopupMenuItem( value: 'bios', child: Text('Import a BIOS file (optional)'), ), const PopupMenuItem(value: 'about', child: Text('About')), ], ), ], ), floatingActionButton: FloatingActionButton.extended( onPressed: _busy ? null : _addGame, icon: _busy ? const SizedBox.square( dimension: 20, child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.add), label: const Text('Add game'), ), body: FutureBuilder>( future: _games, builder: (context, snapshot) { final games = snapshot.data; if (games == null) { return const Center(child: CircularProgressIndicator()); } if (games.isEmpty) return const _EmptyLibrary(); return ListView.builder( padding: const EdgeInsets.only(bottom: 88), itemCount: games.length, itemBuilder: (context, index) { final game = games[index]; return ListTile( leading: const Icon(Icons.videogame_asset_outlined), title: Text(game.title), subtitle: Text( [ if (game.gameCode.isNotEmpty) game.gameCode, game.fileName, if (game.hasSave) 'saved', ].join(' ยท '), ), onTap: () => _play(game), onLongPress: () => _delete(game), trailing: IconButton( tooltip: 'Remove', icon: const Icon(Icons.delete_outline), onPressed: () => _delete(game), ), ); }, ); }, ), ); } void _showAbout() { showAboutDialog( context: context, applicationName: 'SplashyGBA', children: const [ Text( 'A Game Boy Advance emulator. No games or BIOS files are ' 'included: add ROM files you own. Without a BIOS file, a built-in ' 'replacement is used.\n\n' 'Not emulated yet: sound, the link cable, and the clock and ' 'sensors of some cartridges.', ), ], ); } } class _EmptyLibrary extends StatelessWidget { const _EmptyLibrary(); @override Widget build(BuildContext context) { final textTheme = Theme.of(context).textTheme; return Center( child: Padding( padding: const EdgeInsets.all(32), child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon( Icons.sports_esports_outlined, size: 56, color: Theme.of(context).colorScheme.primary, ), const SizedBox(height: 16), Text('No games yet', style: textTheme.headlineSmall), const SizedBox(height: 12), Text( 'Tap "Add game" and choose a .gba file you own. No games or ' 'BIOS files come with this app.', style: textTheme.bodyLarge, textAlign: TextAlign.center, ), ], ), ), ); } }