diff --git a/.env_local b/.env_local new file mode 100644 index 0000000..52355bb --- /dev/null +++ b/.env_local @@ -0,0 +1,3 @@ +IGDB_CLIENT_ID="secret" +IGDB_TOKEN="secret" +RAWQ_TOKEN="secret" diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index d0293dc..f086f4a 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -33,8 +33,13 @@ jobs: env: KEYSTORE: ${{ secrets.KEYSTORE }} + - name: Decode .env + run: echo "${{ secrets.FLUTTER_ENV }}" > ${{ github.workspace }}/.env + env: + FLUTTER_ENV: ${{ secrets.FLUTTER_ENV }} + - name: Build APK - run: flutter build apk --release --split-per-abi --flavor prod + run: flutter build apk --dart-define-from-file=.env --release --split-per-abi --flavor prod env: KEYSTORE_PASSWD: ${{ secrets.KEYSTORE_PASSWD }} KEYSTORE_ALIAS: ${{ secrets.KEYSTORE_ALIAS }} diff --git a/.gitignore b/.gitignore index a6d3e28..9000aca 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ *.pyc *.swp .DS_Store +.env .atom/ .buildlog/ .history diff --git a/.vscode/launch.json b/.vscode/launch.json index 7b8fa41..286f5b8 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,7 +1,4 @@ { - // Use IntelliSense to learn about possible attributes. - // Hover to view descriptions of existing attributes. - // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", "configurations": [ { @@ -9,7 +6,9 @@ "request": "launch", "type": "dart", "args": [ - "--flavor", "dev" + "--flavor", "dev", + "--dart-define-from-file", + ".env" ] }, { @@ -17,7 +16,9 @@ "request": "launch", "type": "dart", "args": [ - "--flavor", "dev", "--profile" + "--flavor", "dev", "--profile", + "--dart-define-from-file", + ".env" ] }, { @@ -25,7 +26,9 @@ "request": "launch", "type": "dart", "args": [ - "--flavor", "prod" + "--flavor", "prod", + "--dart-define-from-file", + ".env" ] }, { diff --git a/lib/app/(public)/config/edit_platform_page.dart b/lib/app/(public)/config/edit_platform_page.dart index 8712f24..457a254 100644 --- a/lib/app/(public)/config/edit_platform_page.dart +++ b/lib/app/(public)/config/edit_platform_page.dart @@ -7,6 +7,7 @@ import 'package:gap/gap.dart'; import 'package:routefly/routefly.dart'; import 'package:yuno/app/interactor/atoms/app_atom.dart'; import 'package:yuno/app/interactor/atoms/game_atom.dart'; +import 'package:yuno/app/interactor/atoms/platform_atom.dart'; import 'package:yuno/app/interactor/models/app_model.dart'; import 'package:yuno/app/interactor/models/embeds/game.dart'; @@ -304,7 +305,7 @@ class _EditPlatformPageState extends State { if (selectedDirectory != null) { setState(() { platform = platform.copyWith( - folder:selectedDirectory, + folder: selectedDirectory, ); }); } @@ -335,14 +336,27 @@ class _EditPlatformPageState extends State { if (platform.id != -1) const Gap(17), FloatingActionButton( heroTag: 'save', - onPressed: () { + onPressed: () async { + + final result = platform.validator(); + if (result != null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(result), + ), + ); + return; + } + + + Routefly.pop(context); + if (isEditing) { updatePlatform(platform); } else { - createPlatform(platform); - } - if (context.mounted) { - Routefly.pop(context); + await createPlatform(platform); + platform = platformsState.value.firstWhere((e) => e.category == platform.category); + await syncPlatform(platform); } }, child: const Icon(Icons.save), diff --git a/lib/app/(public)/config/widgets/platform_widget.dart b/lib/app/(public)/config/widgets/platform_widget.dart index b3f75ae..41c3f73 100644 --- a/lib/app/(public)/config/widgets/platform_widget.dart +++ b/lib/app/(public)/config/widgets/platform_widget.dart @@ -6,6 +6,8 @@ import 'package:yuno/app/core/widgets/animated_floating_action_button.dart'; import 'package:yuno/app/interactor/atoms/platform_atom.dart'; import '../../../../routes.dart'; +import '../../../core/widgets/animated_sync_button.dart'; +import '../../../interactor/actions/platform_action.dart'; class PlatformWidget extends StatelessWidget { final Animation transitionAnimation; @@ -22,6 +24,7 @@ class PlatformWidget extends StatelessWidget { return Scaffold( body: ListView.builder( + padding: const EdgeInsets.only(bottom: 100), itemCount: platforms.length, itemBuilder: (_, index) { final platform = platforms[index]; @@ -56,13 +59,21 @@ class PlatformWidget extends StatelessWidget { trailing: Row( mainAxisSize: MainAxisSize.min, children: [ - IconButton( - icon: const Icon(Icons.sync), - onPressed: () async {}, + AnimatedSyncButton( + isSyncing: platformSyncState.value.contains(platform.id), + onPressed: () async { + if(isPlatformSyncing) { + return; + } + await syncPlatform(platform); + }, ), IconButton( icon: const Icon(Icons.edit), onPressed: () async { + if(isPlatformSyncing) { + return; + } Routefly.push( routePaths.config.editPlatform, arguments: platform, diff --git a/lib/app/(public)/home_page.dart b/lib/app/(public)/home_page.dart index b4decd7..9a9409b 100644 --- a/lib/app/(public)/home_page.dart +++ b/lib/app/(public)/home_page.dart @@ -19,7 +19,9 @@ import '../core/widgets/background/background.dart'; import '../core/widgets/card_tile/card_tile.dart'; import '../core/widgets/command_bar.dart'; import '../interactor/actions/game_action.dart'; +import '../interactor/actions/platform_action.dart'; import '../interactor/atoms/gamepad_atom.dart'; +import '../interactor/atoms/platform_atom.dart'; import '../interactor/services/gamepad_service.dart'; Route routeBuilder(BuildContext context, RouteSettings settings) { @@ -65,6 +67,8 @@ class _HomePageState extends State { late RxDisposer _disposer; + BuildContext? _dialogContext; + @override void initState() { super.initState(); @@ -76,8 +80,10 @@ class _HomePageState extends State { DateTime.now().difference(_lastOpenGameAt!).inSeconds > 1; } + + void handleKey(GamepadButton? event) { - if (Routefly.currentOriginalPath != routePaths.home || event == null) { + if (Routefly.currentOriginalPath != routePaths.home || event == null || _dialogContext?.mounted == true) { return; } @@ -114,13 +120,13 @@ class _HomePageState extends State { void bayx(GamepadButton event) { switch (event) { case GamepadButton.buttonA: - openApps(); - case GamepadButton.buttonB: openGame(); + case GamepadButton.buttonB: + openApps(); case GamepadButton.buttonX: - openSettings(); - case GamepadButton.buttonY: favorite(); + case GamepadButton.buttonY: + openSettings(); default: } } @@ -128,18 +134,24 @@ class _HomePageState extends State { void abxy(GamepadButton event) { switch (event) { case GamepadButton.buttonA: - openGame(); - case GamepadButton.buttonB: openApps(); + case GamepadButton.buttonB: + openGame(); case GamepadButton.buttonX: - favorite(); - case GamepadButton.buttonY: openSettings(); + case GamepadButton.buttonY: + favorite(); default: } } - void favorite() {} + void favorite() { + final game = games[selectedItemIndex]; + final newGame = game.copyWith( + isFavorite: !game.isFavorite, + ); + updateGame(game, newGame); + } void resetConfig() { setState(() { @@ -241,6 +253,166 @@ class _HomePageState extends State { }); } + Future changeTitle() async { + final game = games[selectedItemIndex]; + var newTitle = game.name; + await showDialog( + context: context, + builder: (context) { + _dialogContext = context; + return AlertDialog( + title: Text('Change title'), + content: TextFormField( + initialValue: game.name, + onChanged: (value) { + newTitle = value; + }, + decoration: InputDecoration( + border: OutlineInputBorder(), + labelText: 'Title', + ), + ), + actions: [ + TextButton( + onPressed: () { + Navigator.pop(context); + }, + child: Text('Cancel'), + ), + TextButton( + onPressed: () { + if (newTitle.isEmpty) { + return; + } + updateGame( + game, + game.copyWith(name: newTitle), + ); + Navigator.pop(context); + setState(() { + title = newTitle; + }); + }, + child: Text('Ok'), + ), + ], + ); + }, + ); + } + + Future changeCover() async { + await showDialog( + context: context, + builder: (_) { + return RxBuilder(builder: (context) { + _dialogContext = context; + + final game = games[selectedItemIndex]; + return AlertDialog( + title: Text('Change cover'), + content: Align( + child: AspectRatio( + aspectRatio: 3 / 4, + //height: 250, + //width: 188, + child: Hero( + tag: game.name, + child: CardTile( + game: game, + colorSelect: Colors.transparent, + transitionAnimation: widget.transitionAnimation, + selected: true, + onTap: () {}, + onLongPressed: () {}, + index: 0, + gamesLength: 1, + ), + ), + ), + ), + actions: [ + TextButton( + onPressed: () { + updateGame(game, game.copyWith(image: '')); + Navigator.pop(context); + }, + child: Text('Remove'), + ), + TextButton( + onPressed: () { + selectCover(game); + }, + child: Text('Select'), + ), + TextButton( + onPressed: () { + Navigator.pop(context); + }, + child: Text('Ok'), + ), + ], + ); + }); + }, + ); + } + + Future resync() async { + final game = games[selectedItemIndex]; + final platform = await updateGame(game, game.copyWith(isSynced: false)); + await syncPlatform(platform); + } + + Future gameMenu() async { + final game = games[selectedItemIndex]; + showDialog( + context: context, + builder: (context) { + _dialogContext = context; + + return AlertDialog( + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + title: Text('Play'), + onTap: () { + openGame(); + Navigator.pop(context); + }), + ListTile( + title: Text(game.isFavorite ? 'Unfavorite' : 'Favorite'), + onTap: () { + favorite(); + Navigator.pop(context); + }), + ListTile( + title: Text('Change title'), + onTap: () { + Navigator.pop(context); + changeTitle(); + }), + ListTile( + title: Text('Change Cover'), + onTap: () { + Navigator.pop(context); + changeCover(); + }), + ListTile( + title: Text('Resync'), + onTap: () { + Navigator.pop(context); + resync(); + }, + ), + ], + ), + ); + }, + ); + } + @override void dispose() { scrollController.dispose(); @@ -361,7 +533,7 @@ class _HomePageState extends State { padding: const EdgeInsets.only(bottom: 120), gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: crossAxisCount, - childAspectRatio: 3 / 5, + childAspectRatio: 3 / 4, ), itemCount: games.length, itemBuilder: (context, index) { @@ -369,17 +541,24 @@ class _HomePageState extends State { index: index, key: ValueKey(index), controller: scrollController, - child: CardTile( - game: games[index], - colorSelect: colorScheme.primary, - transitionAnimation: widget.transitionAnimation, - selected: selectedItemIndex == index, - onTap: () { - handlerSelect(index); - openGame(); - }, - index: index, - gamesLength: games.length, + child: Hero( + tag: games[index].name, + child: CardTile( + game: games[index], + colorSelect: colorScheme.primary, + transitionAnimation: widget.transitionAnimation, + selected: selectedItemIndex == index, + onTap: () { + handlerSelect(index); + openGame(); + }, + onLongPressed: () { + handlerSelect(index); + gameMenu(); + }, + index: index, + gamesLength: games.length, + ), ), ); }, @@ -389,6 +568,7 @@ class _HomePageState extends State { ), bottomNavigationBar: NavigationCommand( colorScheme: colorScheme, + isSyncing: isPlatformSyncing, onApps: openApps, onSettings: openSettings, onFavorite: () { diff --git a/lib/app/app_widget.dart b/lib/app/app_widget.dart index c5723bd..a7c8a76 100644 --- a/lib/app/app_widget.dart +++ b/lib/app/app_widget.dart @@ -21,6 +21,9 @@ class AppWidget extends StatelessWidget { routerConfig: Routefly.routerConfig( routes: routes, initialPath: routePaths.splash, + observers: [ + HeroController(), + ], routeBuilder: (context, settings, child) { return PageRouteBuilder( settings: settings, diff --git a/lib/app/core/assets/sounds.dart b/lib/app/core/assets/sounds.dart index b9f2cff..50b772c 100644 --- a/lib/app/core/assets/sounds.dart +++ b/lib/app/core/assets/sounds.dart @@ -2,7 +2,7 @@ import 'package:flutter/services.dart'; import 'package:soundpool/soundpool.dart'; import 'package:yuno/app/interactor/atoms/config_atom.dart'; -final _pool = Soundpool.fromOptions(options: const SoundpoolOptions(streamType: StreamType.notification)); +final _pool = Soundpool.fromOptions(options: const SoundpoolOptions(streamType: StreamType.music)); const _clickSound = 'assets/sounds/click.mp3'; int _clickSoundId = 0; diff --git a/lib/app/core/constants/env.dart b/lib/app/core/constants/env.dart new file mode 100644 index 0000000..a6634b2 --- /dev/null +++ b/lib/app/core/constants/env.dart @@ -0,0 +1,3 @@ +const igdbClientId = String.fromEnvironment('IGDB_CLIENT_ID'); +const igdbToken = String.fromEnvironment('IGDB_TOKEN'); +const rawqToken = String.fromEnvironment('RAWQ_TOKEN'); diff --git a/lib/app/core/widgets/animated_sync_button.dart b/lib/app/core/widgets/animated_sync_button.dart new file mode 100644 index 0000000..6854548 --- /dev/null +++ b/lib/app/core/widgets/animated_sync_button.dart @@ -0,0 +1,73 @@ +import 'dart:math'; +import 'dart:ui'; + +import 'package:flutter/material.dart'; + +class AnimatedSyncButton extends StatefulWidget { + final bool isSyncing; + final VoidCallback? onPressed; + const AnimatedSyncButton({ + required this.isSyncing, + required this.onPressed, + super.key, + }); + + @override + State createState() => _AnimatedSyncButtonState(); +} + +class _AnimatedSyncButtonState extends State + with SingleTickerProviderStateMixin { + late final AnimationController controller; + @override + void initState() { + super.initState(); + controller = AnimationController( + vsync: this, + duration: const Duration(seconds: 2), + ); + + controller.addListener(() { + setState(() {}); + }); + + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + animate(); + }); + } + + @override + void didUpdateWidget(covariant AnimatedSyncButton oldWidget) { + super.didUpdateWidget(oldWidget); + + if (oldWidget.isSyncing != widget.isSyncing) { + animate(); + } + } + + void animate(){ + if (widget.isSyncing) { + controller.repeat(); + } else { + controller.forward(); + } + } + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return IconButton( + onPressed: widget.onPressed, + icon: Transform.rotate( + angle: controller.value * (pi * 2), + alignment: Alignment.center, + child: const Icon(Icons.sync), + ), + ); + } +} diff --git a/lib/app/core/widgets/card_tile/card_tile.dart b/lib/app/core/widgets/card_tile/card_tile.dart index 665b0f0..3b1e2cf 100644 --- a/lib/app/core/widgets/card_tile/card_tile.dart +++ b/lib/app/core/widgets/card_tile/card_tile.dart @@ -2,7 +2,9 @@ import 'dart:io'; import 'dart:ui'; import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; import 'package:gap/gap.dart'; +import 'package:yuno/app/interactor/atoms/game_atom.dart'; import 'package:yuno/app/interactor/models/embeds/game.dart'; class CardTile extends StatelessWidget { @@ -13,6 +15,7 @@ class CardTile extends StatelessWidget { final bool selected; final Game game; final Color colorSelect; + final void Function() onLongPressed; const CardTile({ super.key, @@ -23,6 +26,7 @@ class CardTile extends StatelessWidget { required this.colorSelect, this.onTap, this.selected = false, + required this.onLongPressed, }); Widget noImage() { @@ -97,6 +101,7 @@ class CardTile extends StatelessWidget { borderRadius: borderRadius, ), child: InkWell( + onLongPress: onLongPressed, borderRadius: borderRadius, onTap: onTap, child: AnimatedContainer( @@ -114,7 +119,28 @@ class CardTile extends StatelessWidget { ) : null, ), - child: game.hasImage ? null : noImage(), + child: Stack( + children: [ + if (!game.hasImage) Center(child: noImage()), + if (game.isFavorite) + Align( + alignment: Alignment.bottomRight, + child: Container( + decoration: const BoxDecoration( + shape: BoxShape.circle, + color: Colors.red, + ), + child: Padding( + padding: const EdgeInsets.all(7.0), + child: SvgPicture.asset( + defaultCategoryFavorite.image, + width: 20, + ), + ), + ), + ), + ], + ), ), ), ), diff --git a/lib/app/core/widgets/command_bar.dart b/lib/app/core/widgets/command_bar.dart index 667232b..2a8cbaf 100644 --- a/lib/app/core/widgets/command_bar.dart +++ b/lib/app/core/widgets/command_bar.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:gap/gap.dart'; +import 'package:yuno/app/core/widgets/animated_sync_button.dart'; class NavigationCommand extends StatelessWidget { final VoidCallback? onApps; @@ -7,6 +8,7 @@ class NavigationCommand extends StatelessWidget { final VoidCallback? onFavorite; final VoidCallback? onSettings; final ColorScheme colorScheme; + final bool isSyncing; const NavigationCommand({ super.key, @@ -14,6 +16,7 @@ class NavigationCommand extends StatelessWidget { this.onFavorite, this.onSettings, this.onPlay, + this.isSyncing = false, required this.colorScheme, }); @@ -45,6 +48,15 @@ class NavigationCommand extends StatelessWidget { textColor: colorScheme.background, ), const Spacer(), + if(isSyncing) + Row( + mainAxisSize: MainAxisSize.min, + children: [ + AnimatedSyncButton(isSyncing: true, onPressed: (){},), + Text('Syncing...'), + ], + ), + const Spacer(), LabelButton( label: 'Favorite', buttonText: 'X', diff --git a/lib/app/data/repositories/isar/adapters/platform_adapter.dart b/lib/app/data/repositories/isar/adapters/platform_adapter.dart index 5b31713..2aed008 100644 --- a/lib/app/data/repositories/isar/adapters/platform_adapter.dart +++ b/lib/app/data/repositories/isar/adapters/platform_adapter.dart @@ -19,7 +19,10 @@ abstract class PlatformAdapter { } data.category = model.category.id; - data.games = model.games.map((e) => gameFromModel(e)).toList(); + + final games = model.games.map((e) => gameFromModel(e)).toList(); + games.sort((a, b) => a.name.compareTo(b.name)); + data.games = games; data.folder = model.folder; data.lastUpdate = DateTime.now(); data.playerPackageId = model.player?.app.package; diff --git a/lib/app/data/repositories/isar/isar_platform_repository.dart b/lib/app/data/repositories/isar/isar_platform_repository.dart index 6f35344..ef7e009 100644 --- a/lib/app/data/repositories/isar/isar_platform_repository.dart +++ b/lib/app/data/repositories/isar/isar_platform_repository.dart @@ -9,7 +9,7 @@ import 'isar_datasource.dart'; class IsarPlatformRepository extends PlatformRepository { @override Future> fetchPlatforms() async { - final datas = await IsarDatasource.isar.platformDatas.where().findAll(); + final datas = await IsarDatasource.isar.platformDatas.where().sortByCategory().findAll(); return datas.map((e) => PlatformAdapter.platformFromData(e)).toList(); } diff --git a/lib/app/data/repositories/uno_sync_repository.dart b/lib/app/data/repositories/uno_sync_repository.dart new file mode 100644 index 0000000..75e0754 --- /dev/null +++ b/lib/app/data/repositories/uno_sync_repository.dart @@ -0,0 +1,65 @@ +import 'dart:io'; + +import 'package:path/path.dart'; +import 'package:uno/uno.dart'; +import 'package:yuno/app/interactor/models/embeds/game.dart'; + +import '../../core/constants/env.dart'; +import '../../interactor/repositories/sync_repository.dart'; +import 'package:path_provider/path_provider.dart' as pathProvider; + +class UnoSyncRepository implements SyncRepository { + final Uno uno; + + UnoSyncRepository({required this.uno}); + + @override + Future syncIGDB(Game game) async { + + final response = await uno.post('https://api.igdb.com/v4/games', + headers: { + 'content-type': 'text/plain', + 'client-id': igdbClientId, + 'authorization': 'Bearer $igdbToken', + }, + data: 'fields artworks,collection,cover.*, first_release_date,genres.*,name,summary; search "${game.name}"; limit 2;',); + + // prevent multiples calls + await Future.delayed(const Duration(seconds: 1)); + + if(response.data.isEmpty) { + return game; + } + + final json = response.data[0]; + + var image = "https:${json['cover']['url']}"; + image = image.replaceAll('t_thumb', 't_cover_big'); + + final dirPath = await pathProvider.getApplicationDocumentsDirectory(); + final imageName = basename(image); + final pathSeparator = Platform.pathSeparator; + final imageFile = File('${dirPath.path}${pathSeparator}$imageName'); + + if(!imageFile.existsSync()) { + final imageData = await uno.get(image, responseType: ResponseType.arraybuffer); + await imageFile.writeAsBytes(imageData.data); + } + + + final metaGame = game.copyWith( + isSynced: true, + description: json['summary'], + image: imageFile.path, + genre: json['genres'][0]['name'], + ); + + return metaGame; + } + + @override + Future syncRAWG(Game game) { + // TODO: implement syncRAWG + throw UnimplementedError(); + } +} diff --git a/lib/app/interactor/actions/game_action.dart b/lib/app/interactor/actions/game_action.dart index 1a6ebd0..cafe40c 100644 --- a/lib/app/interactor/actions/game_action.dart +++ b/lib/app/interactor/actions/game_action.dart @@ -2,13 +2,17 @@ import 'dart:io'; +import 'package:file_selector/file_selector.dart'; import 'package:flutter/material.dart'; +import 'package:yuno/app/interactor/actions/platform_action.dart'; import 'package:yuno/app/interactor/actions/player_action.dart'; import 'package:yuno/app/interactor/atoms/platform_atom.dart'; +import 'package:yuno/app/interactor/models/platform_model.dart'; import '../atoms/game_atom.dart'; import '../models/embeds/game.dart'; import 'apps_action.dart' as appsAction; +import 'package:path_provider/path_provider.dart' as pathProvider; Future precacheGameImages(BuildContext context) async { for (var game in gamesState) { @@ -27,6 +31,58 @@ Future precacheGameImages(BuildContext context) async { } } +Future updateGame(Game game, Game newGame) async { + final platform = platformsState.value.firstWhere((p) { + return p.games.contains(game); + }); + + final index = platform.games.indexOf(game); + platform.games[index] = newGame; + await updatePlatform(platform); + return platform; +} + +Future selectCover(Game game) async { + const XTypeGroup typeGroup = XTypeGroup( + label: 'images', + extensions: ['jpg', 'png'], + ); + final file = await openFile(acceptedTypeGroups: [typeGroup]); + + if (file == null) { + return; + } + + final extension = getExtensionFromMimeType(file.mimeType!); + final name = '${DateTime.now().millisecondsSinceEpoch}.$extension'; + final divide = Platform.pathSeparator; + + final imagePath = + '${(await pathProvider.getApplicationDocumentsDirectory()).path}${divide}images${divide}${name}'; + print(imagePath); + + final bytes = await file.readAsBytes(); + final imageFile = File(imagePath); + await imageFile.create(recursive: true); + await imageFile.writeAsBytes(bytes); + + final color = await getDominatingColor(imagePath); + + await updateGame(game, game.copyWith(image: imagePath, imageColor: color)); +} + +String getExtensionFromMimeType(String mimeType) { + switch (mimeType) { + case 'image/jpeg': + case 'image/jpg': + return 'jpg'; + case 'image/png': + return 'png'; + default: + return 'unknown'; + } +} + Future openGameWithPlayer(Game game) async { final platform = platformsState.value.firstWhere((p) => p.games.contains(game)).player; diff --git a/lib/app/interactor/actions/platform_action.dart b/lib/app/interactor/actions/platform_action.dart index 2589379..6d18208 100644 --- a/lib/app/interactor/actions/platform_action.dart +++ b/lib/app/interactor/actions/platform_action.dart @@ -1,37 +1,19 @@ // ignore_for_file: use_build_context_synchronously + import 'dart:io'; import 'package:flutter/material.dart'; import 'package:media_store_plus/media_store_plus.dart'; import 'package:yuno/app/interactor/models/platform_model.dart'; import 'package:yuno/app/interactor/repositories/platform_repository.dart'; +import 'package:yuno/app/interactor/repositories/sync_repository.dart'; import 'package:yuno/injector.dart'; import '../atoms/platform_atom.dart'; import '../models/embeds/game.dart'; import 'game_action.dart'; -// igdp 1o179i97b6l23szlizbu6hdknrg9so - -// > POST /v4/games HTTP/2 -// > Host: api.igdb.com -// > cookie: __cf_bm=xO7z4BfO.LCPPT_pEgqUYYtKcOVWVUBxl3P.jWEVYJY-1705531429-1-ARyT4iQbBQJYySOWqftllPZL9zE+kTmThYfHMqkmO6koa5Xzts+qtg/4q1WLKx0X56SwKAF85f1s2f/bxYZcAXQ= -// > content-type: text/plain -// > user-agent: insomnia/2023.5.8 -// > client-id: tmj9jsx0fuamcftxqecvsybnoh59lm -// > authorization: Bearer 1o179i97b6l23szlizbu6hdknrg9so -// > accept: */* -// > content-length: 114 -// fields artworks,collection,cover.*, first_release_date,genres.*,name,summary; search "super mario world"; limit 1; - -// rawg 09c741277de347b79d8cbb55ec394b54 - -//GET /api/games?key=09c741277de347b79d8cbb55ec394b54&search=Super%20mario%20odyssey HTTP/2 -//> Host: api.rawg.io -//> user-agent: insomnia/2023.5.8 -//> accept: */* - Future firstInitialization(BuildContext context) async { await fetchPlatforms(); await precacheGameImages(context); @@ -46,7 +28,6 @@ Future createPlatform(PlatformModel platform) async { final repository = injector(); final games = await _getGames(platform); platform = platform.copyWith(games: games); - await repository.createPlatform(platform); await fetchPlatforms(); } @@ -56,8 +37,6 @@ Future> _getGames(PlatformModel platform) async { return platform.games; } - - final games = []; final media = MediaStore(); @@ -67,7 +46,6 @@ Future> _getGames(PlatformModel platform) async { return []; } - final files = documents // .children .where((doc){ @@ -77,7 +55,7 @@ Future> _getGames(PlatformModel platform) async { for (var file in files) { games.add(Game( - name: file.name ?? '', + name: cleanName(file.name ?? ''), path: file.uriString ?? '', description: '', image: '', @@ -87,6 +65,47 @@ Future> _getGames(PlatformModel platform) async { return games; } +Future syncPlatform(PlatformModel platform) async { + if(isPlatformSyncing) { + return; + } + + platformSyncState.value = {...platformSyncState.value, platform.id}; + + final repository = injector(); + + for (var i = 0; i < platform.games.length; i++) { + if (platform.games[i].isSynced) continue; + if (platform.games[i].image.isNotEmpty) { + final color = await getDominatingColor(platform.games[i].image); + platform.games[i] = platform.games[i].copyWith(imageColor: color); + } else { + try { + var metaGame = await repository.syncIGDB(platform.games[i]); + final color = await getDominatingColor(metaGame.image); + metaGame = metaGame.copyWith(imageColor: color); + platform.games[i] = metaGame; + } catch (e) { + continue; + } + } + } + + await updatePlatform(platform); + platformSyncState.value.remove(platform.id); + platformSyncState(); +} + +Future getDominatingColor(String imagePath) async { + final imageFile = File(imagePath); + if(!imageFile.existsSync()){ + return null; + } + final scheme = await ColorScheme.fromImageProvider(provider: FileImage(imageFile), + brightness: Brightness.dark,); + return scheme.primary; +} + String cleanName(String name) { final index = name.indexOf(RegExp(r'[.(\[]')) - 1; return name.substring(0 , index <= 0 ? name.length : index).trim(); @@ -104,8 +123,6 @@ Future deletePlatform(PlatformModel platform) async { await fetchPlatforms(); } -Future syncPlatform(PlatformModel platform) async {} - String convertContentUriToFilePath(String contentUri) { Uri uri = Uri.parse(contentUri); diff --git a/lib/app/interactor/actions/player_action.dart b/lib/app/interactor/actions/player_action.dart index 57c779e..48ab5db 100644 --- a/lib/app/interactor/actions/player_action.dart +++ b/lib/app/interactor/actions/player_action.dart @@ -29,6 +29,12 @@ final _defaultAppIntent = { 'ROM': convertContentUriToFilePath(g.path), 'LIBRETRO': '/data/data/com.retroarch.aarch64/cores/${p.extra}_libretro_android.so', + 'CONFIGFILE': '/storage/emulated/0/Android/data/com.retroarch.aarch64/files/retroarch.cfg', + 'DATADIR': '/data/data/com.retroarch.aarch64', + 'APK': '/data/app/com.retroarch.aarch64-1/base.apk', + 'SDCARD': '/storage/emulated/0', + 'EXTERNAL': '/storage/emulated/0/Android/data/com.retroarch.aarch64/files', + 'IME': 'com.android.inputmethod.latin/.LatinIME', }, ); }, diff --git a/lib/app/interactor/atoms/game_atom.dart b/lib/app/interactor/atoms/game_atom.dart index 7e79b84..3c56a0c 100644 --- a/lib/app/interactor/atoms/game_atom.dart +++ b/lib/app/interactor/atoms/game_atom.dart @@ -10,7 +10,7 @@ List get gamesState { return platformsState.value // .map((e) => e.games) .expand((games) => games) - .toList(); + .toList()..sort((a, b) => a.name.compareTo(b.name)); } final gameSearchState = Atom( @@ -59,9 +59,34 @@ List get categoriesFoSelectState { final categorieState = [ GameCategory(name: 'Android', image: img.androidSVG, id: 'android'), - GameCategory(name: 'Nintendo Switch', image: img.switchSVG, id: 'switch'), - GameCategory(name: 'Super Nintendo', image: img.switchSVG, id: 'snes'), - GameCategory(name: 'Playstation 1', image: img.ps1SVG, id: 'ps1'), - GameCategory(name: 'Playstation 2', image: img.ps2SVG, id: 'ps2'), - GameCategory(name: 'Playstation Portable', image: img.pspSVG, id: 'psp'), + GameCategory( + name: 'Nintendo Switch', + image: img.switchSVG, + id: 'switch', + extensions: ['nsp', 'xci'], + ), + GameCategory( + name: 'Super Nintendo', + image: img.switchSVG, + id: 'snes', + extensions: ['smc', 'sfc', 'zip'], + ), + GameCategory( + name: 'Playstation 1', + image: img.ps1SVG, + id: 'ps1', + extensions: ['bin', 'cue', 'iso'], + ), + GameCategory( + name: 'Playstation 2', + image: img.ps2SVG, + id: 'ps2', + extensions: ['bin', 'cue', 'iso'], + ), + GameCategory( + name: 'Playstation Portable', + image: img.pspSVG, + id: 'psp', + extensions: ['cso', 'iso'], + ), ]; diff --git a/lib/app/interactor/atoms/platform_atom.dart b/lib/app/interactor/atoms/platform_atom.dart index eddb5f5..2e36d76 100644 --- a/lib/app/interactor/atoms/platform_atom.dart +++ b/lib/app/interactor/atoms/platform_atom.dart @@ -3,3 +3,6 @@ import 'package:asp/asp.dart'; import '../models/platform_model.dart'; final platformsState = Atom>([]); + +final platformSyncState = Atom>({}); +bool get isPlatformSyncing => platformSyncState.value.isNotEmpty; diff --git a/lib/app/interactor/models/embeds/game.dart b/lib/app/interactor/models/embeds/game.dart index 6e5a842..744815d 100644 --- a/lib/app/interactor/models/embeds/game.dart +++ b/lib/app/interactor/models/embeds/game.dart @@ -28,4 +28,30 @@ class Game { this.genre, this.publisher, }); + + Game copyWith({ + Player? overradedPlayer, + String? name, + String? description, + String? image, + String? path, + bool? isFavorite, + Color? imageColor, + String? genre, + bool? isSynced, + String? publisher, + }) { + return Game( + overradedPlayer: overradedPlayer ?? this.overradedPlayer, + name: name ?? this.name, + imageColor: imageColor ?? this.imageColor, + description: description ?? this.description, + image: image ?? this.image, + path: path ?? this.path, + isFavorite: isFavorite ?? this.isFavorite, + genre: genre ?? this.genre, + isSynced: isSynced ?? this.isSynced, + publisher: publisher ?? this.publisher, + ); + } } diff --git a/lib/app/interactor/models/embeds/game_category.dart b/lib/app/interactor/models/embeds/game_category.dart index 787eb43..1bc3019 100644 --- a/lib/app/interactor/models/embeds/game_category.dart +++ b/lib/app/interactor/models/embeds/game_category.dart @@ -15,7 +15,8 @@ class GameCategory { this.extensions = const [], }); - bool checkFileExtension(String name) => true; + bool checkFileExtension(String name) => extensions // + .any((extension) => name.toLowerCase().endsWith(extension)); @override bool operator ==(covariant GameCategory other) { diff --git a/lib/app/interactor/models/embeds/player.dart b/lib/app/interactor/models/embeds/player.dart index e7b9ebc..9cfbf72 100644 --- a/lib/app/interactor/models/embeds/player.dart +++ b/lib/app/interactor/models/embeds/player.dart @@ -14,6 +14,7 @@ class Player { this.extra, }); + Player copyWith({ AppModel? app, String? extra, diff --git a/lib/app/interactor/models/platform_model.dart b/lib/app/interactor/models/platform_model.dart index 95157f2..b64101e 100644 --- a/lib/app/interactor/models/platform_model.dart +++ b/lib/app/interactor/models/platform_model.dart @@ -23,10 +23,37 @@ class PlatformModel { required this.games, }); + String? validator() { + if (category.name.isEmpty) { + return 'You must select a category'; + } + if (category.id == 'android' && games.isEmpty) { + return 'You must select at least one app'; + } + + if (category.id != 'android' && player == null) { + return 'Select a player'; + } + + if (category.id != 'android' // + && + player != null && + player!.app.package.startsWith('com.retroarch') && + player!.extra == null) { + return 'Select a Retroarch Core'; + } + + if (category.id != 'android' && folder.isEmpty) { + return 'Folder cannot be empty'; + } + + return null; + } + static PlatformModel defaultInstance() { return PlatformModel( id: -1, - folder:'', + folder: '', lastUpdate: DateTime.now(), games: [], category: GameCategory(name: '', image: '', id: ''), diff --git a/lib/app/interactor/repositories/sync_repository.dart b/lib/app/interactor/repositories/sync_repository.dart new file mode 100644 index 0000000..70eb42f --- /dev/null +++ b/lib/app/interactor/repositories/sync_repository.dart @@ -0,0 +1,6 @@ +import 'package:yuno/app/interactor/models/embeds/game.dart'; + +abstract class SyncRepository { + Future syncIGDB(Game game); + Future syncRAWG(Game game); +} \ No newline at end of file diff --git a/lib/injector.dart b/lib/injector.dart index b299656..03c87c1 100644 --- a/lib/injector.dart +++ b/lib/injector.dart @@ -1,18 +1,23 @@ import 'dart:io'; import 'package:auto_injector/auto_injector.dart'; +import 'package:uno/uno.dart'; import 'package:yuno/app/data/repositories/apps/android_apps_repository.dart'; import 'package:yuno/app/data/repositories/share_preferences/shared_preference_config_repository.dart'; import 'package:yuno/app/data/services/gamepad/android_gamepad_service.dart'; import 'package:yuno/app/interactor/services/gamepad_service.dart'; import 'app/data/repositories/isar/isar_platform_repository.dart'; +import 'app/data/repositories/uno_sync_repository.dart'; import 'app/interactor/repositories/apps_repository.dart'; import 'app/interactor/repositories/config_repository.dart'; import 'app/interactor/repositories/platform_repository.dart'; +import 'app/interactor/repositories/sync_repository.dart'; final injector = AutoInjector( on: (i) { + i.addSingleton(Uno.new); + i.addSingleton(UnoSyncRepository.new); i.addSingleton(SharedPreferenceConfigRepository.new); i.addSingleton(IsarPlatformRepository.new); diff --git a/pubspec.yaml b/pubspec.yaml index 8f0ff5c..242f1a4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 0.0.1+8 +version: 0.0.2+10 environment: sdk: '>=3.2.4 <4.0.0' diff --git a/test/app/interactor/action/platform_action.dart b/test/app/interactor/action/platform_action.dart index 46a5f60..516f07c 100644 --- a/test/app/interactor/action/platform_action.dart +++ b/test/app/interactor/action/platform_action.dart @@ -2,12 +2,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:yuno/app/interactor/actions/platform_action.dart'; void main() { - test('Path to Uri', () { - expect( - addFileInUri('/storage/emulated/0/Emulation/rooms/snes', ''), - 'content://com.android.externalstorage.documents/tree/primary%3AEmulation%2Frooms%2Fsnes/document/primary%3AEmulation%2Frooms%2Fsnes', - ); - }); test('Uri to path', () { expect(