4 Commits

Author SHA1 Message Date
336cb87a06 Implement some sort of filtering 2025-02-26 21:02:07 +01:00
795060a05b Fix grid a fucking gain 2025-02-22 18:13:09 +01:00
bbd3583939 Sort games by name 2025-02-22 18:07:46 +01:00
1c1ac3385b Update README 2025-02-22 17:38:27 +01:00
5 changed files with 162 additions and 88 deletions

View File

@@ -1,16 +1,53 @@
# gamer_updater # Gamer Updater
A new Flutter project. A Flutter application for tracking game version updates via RSS feeds.<br>
The idea is to track updates to games you might have previously played<br>
To simply know how much you might be missing and whether you would have new content to look forward to
## Getting Started ![](git_static/screenshot.jpg)
This project is a starting point for a Flutter application. ## Features
A few resources to get you started if this is your first Flutter project: - Game version tracking
- Automatic version checking through RSS feeds
- Last played date tracking
- Custom version regex patterns
- Thumbnails!
- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) ## Setup
- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook)
For help getting started with Flutter development, view the 1. Clone repository
[online documentation](https://docs.flutter.dev/), which offers tutorials, 2. Install Flutter
samples, guidance on mobile development, and a full API reference. 3. Run `flutter pub get`
4. Run `flutter run`
## Usage
Add games by providing:
- Name
- RSS feed URL
- Version regex pattern
- Optional game image
## Example
If we wanted to track the versions of Rimworld we would enter the rss feed as:
https://store.steampowered.com/feeds/news/app/294100/?cc=HR&l=english
And the version regex pattern as:
`Update (\d+\.\d+\.\d+)`
Seeing as their posts usually follow this convention:
Update 1.5.4104 released
...
Update 1.4.3704 released
In theory the rss link can be any link at all and the version regex can be anything at all<br>
The regex is simply ran on the entirety of the contents of the get request to the rss link<br>
Which means we can also simply search html<br>
But it is not as reliable as the rss because of all the additional shit
Currently the thumbnails must be manually added
They can also be any image at all but the cards are designed to fit the thumbnails from the game update pages:
![](git_static/thumbnails.png)

BIN
git_static/screenshot.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 299 KiB

BIN
git_static/thumbnails.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 662 KiB

View File

@@ -55,10 +55,17 @@ class Game {
throw Exception('No version found for $name'); throw Exception('No version found for $name');
} }
actualVersion = version; actualVersion = version;
lastUpdated = try {
parseRfc822Date( // Some sites use weird ass dogshit fucking formats
response.headers['last-modified'] ?? '', // We cannot really reliably parse every single one of them
).toIso8601String(); // So - fuck it
lastUpdated =
parseRfc822Date(
response.headers['last-modified'] ?? '',
).toIso8601String();
} catch (e) {
lastUpdated = DateTime.now().toIso8601String();
}
} }
} }
@@ -102,7 +109,7 @@ last_updated = excluded.last_updated
static Future<Map<String, Game>> getAll() async { static Future<Map<String, Game>> getAll() async {
final db = DB.db; final db = DB.db;
final games = await db.rawQuery( final games = await db.rawQuery(
'SELECT name, actual_version, last_played, rss_feed_url, version_regex, last_updated, image_data FROM games', 'SELECT name, actual_version, last_played, rss_feed_url, version_regex, last_updated, image_data FROM games ORDER BY name',
); );
return games return games
.map((e) => Game.fromMap(e)) .map((e) => Game.fromMap(e))

View File

@@ -3,6 +3,7 @@ import 'package:gamer_updater/db.dart';
import 'package:gamer_updater/game.dart'; import 'package:gamer_updater/game.dart';
import 'package:gamer_updater/widgets/new_game_card.dart'; import 'package:gamer_updater/widgets/new_game_card.dart';
import 'package:gamer_updater/widgets/game_card.dart'; import 'package:gamer_updater/widgets/game_card.dart';
import 'dart:async';
void main() async { void main() async {
await DB.init(); await DB.init();
@@ -54,6 +55,9 @@ class MyHomePage extends StatefulWidget {
class _MyHomePageState extends State<MyHomePage> { class _MyHomePageState extends State<MyHomePage> {
late Map<String, Game> games = {}; late Map<String, Game> games = {};
String searchQuery = "";
final TextEditingController _searchController = TextEditingController();
Timer? _debounce;
@override @override
void initState() { void initState() {
@@ -61,13 +65,20 @@ class _MyHomePageState extends State<MyHomePage> {
_refreshGames(); _refreshGames();
} }
@override
void dispose() {
_searchController.dispose();
_debounce?.cancel();
super.dispose();
}
Future<void> _refreshGames() async { Future<void> _refreshGames() async {
final games = await GameRepository.getAll(); final games = await GameRepository.getAll();
games.forEach((key, game) { games.forEach((key, game) {
game.updateActualVersion().then((_) { game.updateActualVersion().then((_) {
GameRepository.upsert(game); GameRepository.upsert(game);
setState(() { setState(() {
games[game.name] = game; this.games[game.name] = game;
}); });
}); });
}); });
@@ -76,6 +87,24 @@ class _MyHomePageState extends State<MyHomePage> {
}); });
} }
List<Game> get filteredGames {
if (searchQuery.isEmpty) {
return games.values.toList();
}
return games.values.where((game) =>
game.name.toLowerCase().contains(searchQuery.toLowerCase())
).toList();
}
void _onSearchChanged(String value) {
if (_debounce?.isActive ?? false) _debounce!.cancel();
_debounce = Timer(const Duration(milliseconds: 100), () {
setState(() {
searchQuery = value;
});
});
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
@@ -86,80 +115,81 @@ class _MyHomePageState extends State<MyHomePage> {
IconButton(icon: const Icon(Icons.refresh), onPressed: _refreshGames), IconButton(icon: const Icon(Icons.refresh), onPressed: _refreshGames),
], ],
), ),
body: RefreshIndicator( body: Column(
onRefresh: _refreshGames, children: [
child: SingleChildScrollView( Padding(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8.0),
child: Column( child: TextField(
children: [ controller: _searchController,
for (var i = 0; i < games.length + 1; i += 2) decoration: InputDecoration(
Padding( hintText: 'Search games...',
padding: const EdgeInsets.only(bottom: 8), prefixIcon: const Icon(Icons.search),
child: Row( suffixIcon: searchQuery.isNotEmpty
crossAxisAlignment: CrossAxisAlignment.start, ? IconButton(
children: [ icon: const Icon(Icons.clear),
Expanded( onPressed: () {
child: _searchController.clear();
i < games.length setState(() {
? GameCard( searchQuery = "";
game: games.values.elementAt(i), });
onGameUpdated: (game) async { },
game = await GameRepository.upsert(game); )
setState(() { : null,
games[game.name] = game; border: OutlineInputBorder(
}); borderRadius: BorderRadius.circular(10),
},
onDelete: () async {
await GameRepository.delete(
games.values.elementAt(i),
);
setState(() {
games.remove(
games.values.elementAt(i).name,
);
});
},
)
: NewGameCard(
onGameCreated: (game) async {
game = await GameRepository.upsert(game);
setState(() {
games[game.name] = game;
});
},
),
),
const SizedBox(width: 8),
Expanded(
child:
i + 1 < games.length
? GameCard(
game: games.values.elementAt(i + 1),
onGameUpdated: (game) async {
game = await GameRepository.upsert(game);
setState(() {
games[game.name] = game;
});
},
onDelete: () async {
await GameRepository.delete(
games.values.elementAt(i + 1),
);
setState(() {
games.remove(
games.values.elementAt(i + 1).name,
);
});
},
)
: const SizedBox(), // Empty space for odd number of items
),
],
),
), ),
], ),
onChanged: _onSearchChanged,
),
), ),
), Expanded(
child: RefreshIndicator(
onRefresh: _refreshGames,
child: SingleChildScrollView(
padding: const EdgeInsets.all(8),
child: Wrap(
spacing: 8,
runSpacing: 8,
children: [
...filteredGames.map(
(game) => SizedBox(
key: ValueKey(game.name),
width: (MediaQuery.of(context).size.width - 24) / 2,
child: GameCard(
key: ValueKey('card_${game.name}'),
game: game,
onGameUpdated: (game) async {
game = await GameRepository.upsert(game);
setState(() {
games[game.name] = game;
});
},
onDelete: () async {
await GameRepository.delete(game);
setState(() {
games.remove(game.name);
});
},
),
),
),
SizedBox(
width: (MediaQuery.of(context).size.width - 24) / 2,
child: NewGameCard(
onGameCreated: (game) async {
game = await GameRepository.upsert(game);
setState(() {
games[game.name] = game;
});
},
),
),
],
),
),
),
),
],
), ),
); );
} }