20 Commits

Author SHA1 Message Date
ba8b669399 Trim whitespace for game fields 2025-02-27 11:58:34 +01:00
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
eeceb706d6 Tag apk too 2025-02-22 17:23:03 +01:00
165efcd1a3 Fuck with styles a little 2025-02-22 17:22:23 +01:00
509849db5b Add version to zip file 2025-02-22 17:20:10 +01:00
82f8748177 Fix refresh all button 2025-02-22 17:17:31 +01:00
8fd0511242 Add release script 2025-02-22 17:14:56 +01:00
4f1a947d2b Fix the god damn grid 2025-02-22 17:12:01 +01:00
e5fc67ef43 Add background images for games 2025-02-22 16:58:03 +01:00
0a40e5bbcf Improve error handling and parsing a little 2025-02-22 16:13:18 +01:00
406be305e2 Fix up the grid agin 2025-02-22 15:51:53 +01:00
edb7dbfe05 Trim version 2025-02-22 15:45:55 +01:00
fef5f199c3 Rework data structure to map from list 2025-02-22 15:31:23 +01:00
c94a8f8926 Get rid of that stinky ass weird ass grid sliver 2025-02-22 15:22:49 +01:00
362dea6b08 Design polish 2025-02-22 15:19:43 +01:00
771cf90349 Add a million focus nodes for good measure 2025-02-22 15:15:09 +01:00
b76b51ff34 Don't refresh new games 2025-02-22 15:09:07 +01:00
17 changed files with 714 additions and 240 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

@@ -17,7 +17,8 @@ CREATE TABLE IF NOT EXISTS games (
last_played TEXT NOT NULL, last_played TEXT NOT NULL,
rss_feed_url TEXT NOT NULL, rss_feed_url TEXT NOT NULL,
version_regex TEXT NOT NULL, version_regex TEXT NOT NULL,
last_updated TEXT NOT NULL last_updated TEXT NOT NULL,
image_data BLOB
); );
CREATE INDEX IF NOT EXISTS idx_games_name ON games (name); CREATE INDEX IF NOT EXISTS idx_games_name ON games (name);
CREATE UNIQUE INDEX IF NOT EXISTS idx_games_name_unique ON games (name); CREATE UNIQUE INDEX IF NOT EXISTS idx_games_name_unique ON games (name);

View File

@@ -1,7 +1,7 @@
import 'package:gamer_updater/db.dart'; import 'package:gamer_updater/db.dart';
import 'package:gamer_updater/utils.dart'; import 'package:gamer_updater/utils.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:dart_rss/dart_rss.dart'; import 'dart:typed_data';
class Game { class Game {
final String name; final String name;
@@ -11,6 +11,7 @@ class Game {
String actualVersion; String actualVersion;
String lastUpdated; String lastUpdated;
final String rssFeedUrl; final String rssFeedUrl;
final Uint8List? imageData;
Game({ Game({
required this.name, required this.name,
@@ -19,6 +20,7 @@ class Game {
this.actualVersion = '', this.actualVersion = '',
required this.rssFeedUrl, required this.rssFeedUrl,
this.lastUpdated = '', this.lastUpdated = '',
this.imageData,
}) : _internalVersionRegex = RegExp(versionRegex); }) : _internalVersionRegex = RegExp(versionRegex);
factory Game.fromMap(Map<String, dynamic> map) { factory Game.fromMap(Map<String, dynamic> map) {
@@ -29,31 +31,40 @@ class Game {
rssFeedUrl: map['rss_feed_url'], rssFeedUrl: map['rss_feed_url'],
actualVersion: map['actual_version'], actualVersion: map['actual_version'],
lastUpdated: map['last_updated'], lastUpdated: map['last_updated'],
imageData: map['image_data'],
); );
} }
Future<void> updateActualVersion() async { Future<void> updateActualVersion() async {
if (rssFeedUrl.isEmpty) {
throw Exception('No rss feed url for $name');
}
final response = await http.get(Uri.parse(rssFeedUrl)); final response = await http.get(Uri.parse(rssFeedUrl));
final document = RssFeed.parse(response.body); if (response.statusCode != 200) {
final pages = document.items; throw Exception(
pages.sort((a, b) { 'Failed to update actual version for $name, rss responded with ${response.statusCode}',
var lhs = parseRfc822Date(a.pubDate!); );
var rhs = parseRfc822Date(b.pubDate!); }
return rhs.compareTo(lhs); final body = response.body;
}); final match = _internalVersionRegex.firstMatch(body);
final versions = if (match == null || match.groupCount == 0) {
pages throw Exception('No version found for $name');
.map((e) => _internalVersionRegex.firstMatch(e.title!)?.group(1)) }
.toList(); final version = match.group(1);
if (version == null) {
for (int i = 0; i < versions.length; i++) { throw Exception('No version found for $name');
final version = versions[i]; }
final page = pages[i]; actualVersion = version;
if (version != null) { try {
actualVersion = version; // Some sites use weird ass dogshit fucking formats
lastUpdated = parseRfc822Date(page.pubDate!).toIso8601String(); // We cannot really reliably parse every single one of them
break; // So - fuck it
} lastUpdated =
parseRfc822Date(
response.headers['last-modified'] ?? '',
).toIso8601String();
} catch (e) {
lastUpdated = DateTime.now().toIso8601String();
} }
} }
} }
@@ -86,12 +97,23 @@ last_updated = excluded.last_updated
return game; return game;
} }
static Future<List<Game>> getAll() async { static Future<Game> updateImage(Game game) async {
final db = DB.db;
await db.rawUpdate('UPDATE games SET image_data = ? WHERE name = ?', [
game.imageData,
game.name,
]);
return game;
}
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 FROM games', 'SELECT name, actual_version, last_played, rss_feed_url, version_regex, last_updated, image_data FROM games ORDER BY name',
); );
return games.map((e) => Game.fromMap(e)).toList(); return games
.map((e) => Game.fromMap(e))
.fold<Map<String, Game>>({}, (map, game) => {...map, game.name: game});
} }
static Future<void> delete(Game game) async { static Future<void> delete(Game game) async {

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();
@@ -32,9 +33,9 @@ class MyApp extends StatelessWidget {
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
), ),
textTheme: const TextTheme( textTheme: const TextTheme(
titleLarge: TextStyle(fontSize: 22, fontWeight: FontWeight.bold), titleLarge: TextStyle(fontSize: 30, fontWeight: FontWeight.bold),
bodyLarge: TextStyle(fontSize: 18), bodyLarge: TextStyle(fontSize: 20),
bodyMedium: TextStyle(fontSize: 16), bodyMedium: TextStyle(fontSize: 20),
bodySmall: TextStyle(fontSize: 14), bodySmall: TextStyle(fontSize: 14),
), ),
), ),
@@ -53,7 +54,10 @@ class MyHomePage extends StatefulWidget {
} }
class _MyHomePageState extends State<MyHomePage> { class _MyHomePageState extends State<MyHomePage> {
late List<Game> games = []; late Map<String, Game> games = {};
String searchQuery = "";
final TextEditingController _searchController = TextEditingController();
Timer? _debounce;
@override @override
void initState() { void initState() {
@@ -61,13 +65,46 @@ 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) {
game.updateActualVersion().then((_) {
GameRepository.upsert(game);
setState(() {
this.games[game.name] = game;
});
});
});
setState(() { setState(() {
this.games = games; this.games = games;
}); });
} }
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(
@@ -78,45 +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: GridView.builder( Padding(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8.0),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( child: TextField(
crossAxisCount: 3, controller: _searchController,
childAspectRatio: 1.3, decoration: InputDecoration(
crossAxisSpacing: 4, hintText: 'Search games...',
mainAxisSpacing: 4, prefixIcon: const Icon(Icons.search),
suffixIcon: searchQuery.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
setState(() {
searchQuery = "";
});
},
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
),
),
onChanged: _onSearchChanged,
),
), ),
itemCount: games.length + 1, // +1 for the new game card Expanded(
itemBuilder: (context, index) { child: RefreshIndicator(
if (index == games.length) { onRefresh: _refreshGames,
return NewGameCard( child: SingleChildScrollView(
onGameCreated: (game) async { padding: const EdgeInsets.all(8),
game = await GameRepository.upsert(game); child: Wrap(
setState(() { spacing: 8,
games.add(game); runSpacing: 8,
}); children: [
}, ...filteredGames.map(
); (game) => SizedBox(
} key: ValueKey(game.name),
return GameCard( width: (MediaQuery.of(context).size.width - 24) / 2,
game: games[index], child: GameCard(
onGameUpdated: (game) async { key: ValueKey('card_${game.name}'),
game = await GameRepository.upsert(game); game: game,
setState(() { onGameUpdated: (game) async {
games[index] = game; game = await GameRepository.upsert(game);
}); setState(() {
}, games[game.name] = game;
onDelete: () async { });
await GameRepository.delete(games[index]); },
setState(() { onDelete: () async {
games.removeAt(index); 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;
});
},
),
),
],
),
),
),
),
],
), ),
); );
} }

View File

@@ -27,21 +27,8 @@ DateTime parseRfc822Date(String date) {
final minute = int.parse(timeParts[1]); final minute = int.parse(timeParts[1]);
final second = int.parse(timeParts[2]); final second = int.parse(timeParts[2]);
// Handle the timezone offset
final timezone = parts[5];
final isNegative = timezone.startsWith('-');
final tzHours = int.parse(timezone.substring(1, 3));
final tzMinutes = int.parse(timezone.substring(3, 5));
// Create the DateTime object // Create the DateTime object
DateTime dateTime = DateTime(year, month, day, hour, minute, second); DateTime dateTime = DateTime(year, month, day, hour, minute, second);
// Adjust for timezone
if (isNegative) {
dateTime = dateTime.subtract(Duration(hours: tzHours, minutes: tzMinutes));
} else {
dateTime = dateTime.add(Duration(hours: tzHours, minutes: tzMinutes));
}
return dateTime; return dateTime;
} }

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:gamer_updater/game.dart'; import 'package:gamer_updater/game.dart';
import 'package:image_picker/image_picker.dart';
class GameCard extends StatefulWidget { class GameCard extends StatefulWidget {
final Game game; final Game game;
@@ -19,11 +20,19 @@ class GameCard extends StatefulWidget {
State<GameCard> createState() => _GameCardState(); State<GameCard> createState() => _GameCardState();
} }
class _GameCardState extends State<GameCard> with SingleTickerProviderStateMixin { class _GameCardState extends State<GameCard>
with SingleTickerProviderStateMixin {
late final AnimationController _controller; late final AnimationController _controller;
bool _isLoading = false; bool _isLoading = false;
int _deleteClickCount = 0; int _deleteClickCount = 0;
late TextEditingController _nameController; late TextEditingController _nameController;
late TextEditingController _versionRegexController;
late TextEditingController _rssFeedUrlController;
late TextEditingController _lastPlayedController;
late FocusNode _nameFocus;
late FocusNode _versionRegexFocus;
late FocusNode _rssFeedUrlFocus;
late FocusNode _lastPlayedFocus;
@override @override
void initState() { void initState() {
@@ -33,12 +42,72 @@ class _GameCardState extends State<GameCard> with SingleTickerProviderStateMixin
vsync: this, vsync: this,
); );
_nameController = TextEditingController(text: widget.game.name); _nameController = TextEditingController(text: widget.game.name);
_versionRegexController = TextEditingController(
text: widget.game.versionRegex,
);
_rssFeedUrlController = TextEditingController(text: widget.game.rssFeedUrl);
_lastPlayedController = TextEditingController(text: widget.game.lastPlayed);
_nameFocus = FocusNode();
_versionRegexFocus = FocusNode();
_rssFeedUrlFocus = FocusNode();
_lastPlayedFocus = FocusNode();
_setupFocusListeners();
}
void _setupFocusListeners() {
void updateGame() {
try {
var name =
widget.isNameEditable ? _nameController.text : widget.game.name;
if (name.isNotEmpty) {
widget.onGameUpdated(
Game(
name: name.trim(),
versionRegex: _versionRegexController.text,
lastPlayed: _lastPlayedController.text.trim(),
rssFeedUrl: _rssFeedUrlController.text.trim(),
actualVersion: widget.game.actualVersion,
lastUpdated: widget.game.lastUpdated,
imageData: widget.game.imageData,
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(e.toString())));
}
}
}
_nameFocus.addListener(() {
if (!_nameFocus.hasFocus) updateGame();
});
_versionRegexFocus.addListener(() {
if (!_versionRegexFocus.hasFocus) updateGame();
});
_rssFeedUrlFocus.addListener(() {
if (!_rssFeedUrlFocus.hasFocus) updateGame();
});
_lastPlayedFocus.addListener(() {
if (!_lastPlayedFocus.hasFocus) updateGame();
});
} }
@override @override
void dispose() { void dispose() {
_controller.dispose(); _controller.dispose();
_nameController.dispose(); _nameController.dispose();
_versionRegexController.dispose();
_rssFeedUrlController.dispose();
_lastPlayedController.dispose();
_nameFocus.dispose();
_versionRegexFocus.dispose();
_rssFeedUrlFocus.dispose();
_lastPlayedFocus.dispose();
super.dispose(); super.dispose();
} }
@@ -47,7 +116,15 @@ class _GameCardState extends State<GameCard> with SingleTickerProviderStateMixin
_controller.repeat(); _controller.repeat();
final updatedGame = widget.game; final updatedGame = widget.game;
await updatedGame.updateActualVersion(); try {
await updatedGame.updateActualVersion();
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(e.toString())));
}
}
widget.onGameUpdated(updatedGame); widget.onGameUpdated(updatedGame);
_controller.stop(); _controller.stop();
@@ -72,141 +149,247 @@ class _GameCardState extends State<GameCard> with SingleTickerProviderStateMixin
}); });
} }
Future<void> _pickImage() async {
final picker = ImagePicker();
final image = await picker.pickImage(source: ImageSource.gallery);
if (image != null) {
final imageBytes = await image.readAsBytes();
final updatedGame = await GameRepository.updateImage(
Game(
name: widget.game.name.trim(),
versionRegex: _versionRegexController.text,
lastPlayed: _lastPlayedController.text.trim(),
rssFeedUrl: _rssFeedUrlController.text.trim(),
actualVersion: widget.game.actualVersion,
lastUpdated: widget.game.lastUpdated,
imageData: imageBytes,
),
);
widget.onGameUpdated(updatedGame);
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isUpToDate = widget.game.actualVersion == widget.game.lastPlayed; final isUpToDate = widget.game.actualVersion == widget.game.lastPlayed;
final hasImage = widget.game.imageData != null;
return Card( return Card(
child: Padding( child: Stack(
padding: const EdgeInsets.all(16.0), children: [
child: Column( if (hasImage)
crossAxisAlignment: CrossAxisAlignment.start, Positioned.fill(
children: [ child: ClipRRect(
Row( borderRadius: BorderRadius.circular(12),
mainAxisAlignment: MainAxisAlignment.spaceBetween, child: Opacity(
children: [ opacity: 0.4,
Expanded( child: Image.memory(widget.game.imageData!, fit: BoxFit.cover),
child: TextField( ),
controller: _nameController, ),
style: Theme.of(context).textTheme.titleLarge, ),
enabled: widget.isNameEditable, if (hasImage)
decoration: const InputDecoration.collapsed( Positioned.fill(
hintText: 'New Game', child: Container(
), decoration: BoxDecoration(
onChanged: (value) => widget.onGameUpdated(Game( borderRadius: BorderRadius.circular(12),
name: value, gradient: LinearGradient(
versionRegex: widget.game.versionRegex, begin: Alignment.topCenter,
lastPlayed: widget.game.lastPlayed, end: Alignment.bottomCenter,
rssFeedUrl: widget.game.rssFeedUrl, colors: [
actualVersion: widget.game.actualVersion, Colors.black.withAlpha(110),
lastUpdated: widget.game.lastUpdated, Colors.black.withAlpha(90),
)), Colors.black.withAlpha(70),
],
), ),
), ),
Row( ),
mainAxisSize: MainAxisSize.min, ),
children: [ Padding(
if (widget.onDelete != null) padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 24.0),
IconButton( child: Column(
icon: Icon( crossAxisAlignment: CrossAxisAlignment.start,
Icons.delete, children: [
color: _deleteClickCount > 0 ? Colors.red : null, SizedBox(
height: 40,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: TextField(
controller: _nameController,
focusNode: _nameFocus,
style: Theme.of(context).textTheme.titleLarge
?.copyWith(color: hasImage ? Colors.white : null),
enabled: widget.isNameEditable,
decoration: const InputDecoration.collapsed(
hintText: 'New Game',
),
onSubmitted: (_) => _nameFocus.unfocus(),
), ),
onPressed: _handleDeleteClick,
), ),
RotationTransition( Row(
turns: _controller, mainAxisSize: MainAxisSize.min,
child: IconButton( children: [
icon: const Icon(Icons.refresh), if (!widget.isNameEditable)
onPressed: _isLoading ? null : _refreshVersion, IconButton(
icon: const Icon(Icons.image),
color: hasImage ? Colors.white : null,
onPressed: _pickImage,
),
if (widget.onDelete != null)
IconButton(
icon: Icon(
Icons.delete,
color:
_deleteClickCount > 0
? Colors.red
: (hasImage ? Colors.white : null),
),
onPressed: _handleDeleteClick,
),
if (!widget.isNameEditable)
RotationTransition(
turns: _controller,
child: IconButton(
icon: Icon(
Icons.refresh,
color: hasImage ? Colors.white : null,
),
onPressed: _isLoading ? null : _refreshVersion,
),
),
],
),
],
),
),
const SizedBox(height: 16),
Row(
children: [
SizedBox(
width: 200,
child: Text(
'Version:',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: hasImage ? Colors.white : null,
),
),
),
Text(
widget.game.actualVersion.isEmpty
? 'Unknown'
: widget.game.actualVersion,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: isUpToDate ? Colors.green : Colors.red,
fontWeight: FontWeight.bold,
), ),
), ),
], ],
), ),
], const SizedBox(height: 12),
), Row(
const SizedBox(height: 8), children: [
Row( SizedBox(
children: [ width: 200,
SizedBox( child: Text(
width: 120, 'Last Updated:',
child: Text('Version:', style: Theme.of(context).textTheme.bodyLarge), style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: hasImage ? Colors.white : null,
),
),
),
Text(
widget.game.lastUpdated.isEmpty
? 'Never'
: DateTime.parse(widget.game.lastUpdated).toString(),
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: hasImage ? Colors.white : null,
),
),
],
), ),
Text( const SizedBox(height: 12),
widget.game.actualVersion.isEmpty ? 'Unknown' : widget.game.actualVersion, Row(
style: Theme.of(context).textTheme.bodyMedium?.copyWith( children: [
color: isUpToDate ? Colors.green : Colors.red, SizedBox(
fontWeight: FontWeight.bold, width: 200,
child: Text(
'Last Played:',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: hasImage ? Colors.white : null,
),
),
),
Text(
widget.game.lastPlayed,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: isUpToDate ? Colors.green : Colors.red,
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 16),
const Divider(),
const SizedBox(height: 16),
TextField(
controller: _versionRegexController,
focusNode: _versionRegexFocus,
style: TextStyle(color: hasImage ? Colors.white : null),
decoration: InputDecoration(
labelText: 'Version Regex',
labelStyle: TextStyle(
color: hasImage ? Colors.white70 : null,
),
enabledBorder:
hasImage
? const UnderlineInputBorder(
borderSide: BorderSide(color: Colors.white70),
)
: null,
), ),
onSubmitted: (_) => _versionRegexFocus.unfocus(),
), ),
], TextField(
), controller: _rssFeedUrlController,
Row( focusNode: _rssFeedUrlFocus,
children: [ style: TextStyle(color: hasImage ? Colors.white : null),
SizedBox( decoration: InputDecoration(
width: 120, labelText: 'RSS Feed URL',
child: Text('Last Updated:', style: Theme.of(context).textTheme.bodyLarge), labelStyle: TextStyle(
), color: hasImage ? Colors.white70 : null,
Text( ),
widget.game.lastUpdated.isEmpty ? 'Never' : DateTime.parse(widget.game.lastUpdated).toString(), enabledBorder:
style: Theme.of(context).textTheme.bodyMedium, hasImage
), ? const UnderlineInputBorder(
], borderSide: BorderSide(color: Colors.white70),
), )
Row( : null,
children: [
SizedBox(
width: 120,
child: Text('Last Played:', style: Theme.of(context).textTheme.bodyLarge),
),
Text(
widget.game.lastPlayed,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: isUpToDate ? Colors.green : Colors.red,
fontWeight: FontWeight.bold,
), ),
onSubmitted: (_) => _rssFeedUrlFocus.unfocus(),
),
TextField(
controller: _lastPlayedController,
focusNode: _lastPlayedFocus,
style: TextStyle(color: hasImage ? Colors.white : null),
decoration: InputDecoration(
labelText: 'Last Played',
labelStyle: TextStyle(
color: hasImage ? Colors.white70 : null,
),
enabledBorder:
hasImage
? const UnderlineInputBorder(
borderSide: BorderSide(color: Colors.white70),
)
: null,
),
onSubmitted: (_) => _lastPlayedFocus.unfocus(),
), ),
], ],
), ),
const Divider(), ),
TextField( ],
controller: TextEditingController(text: widget.game.versionRegex),
decoration: const InputDecoration(labelText: 'Version Regex'),
onChanged: (value) => widget.onGameUpdated(Game(
name: widget.isNameEditable ? _nameController.text : widget.game.name,
versionRegex: value,
lastPlayed: widget.game.lastPlayed,
rssFeedUrl: widget.game.rssFeedUrl,
actualVersion: widget.game.actualVersion,
lastUpdated: widget.game.lastUpdated,
)),
),
TextField(
controller: TextEditingController(text: widget.game.rssFeedUrl),
decoration: const InputDecoration(labelText: 'RSS Feed URL'),
onChanged: (value) => widget.onGameUpdated(Game(
name: widget.isNameEditable ? _nameController.text : widget.game.name,
versionRegex: widget.game.versionRegex,
lastPlayed: widget.game.lastPlayed,
rssFeedUrl: value,
actualVersion: widget.game.actualVersion,
lastUpdated: widget.game.lastUpdated,
)),
),
TextField(
controller: TextEditingController(text: widget.game.lastPlayed),
decoration: const InputDecoration(labelText: 'Last Played'),
onChanged: (value) => widget.onGameUpdated(Game(
name: widget.isNameEditable ? _nameController.text : widget.game.name,
versionRegex: widget.game.versionRegex,
lastPlayed: value,
rssFeedUrl: widget.game.rssFeedUrl,
actualVersion: widget.game.actualVersion,
lastUpdated: widget.game.lastUpdated,
)),
),
],
),
), ),
); );
} }

View File

@@ -5,10 +5,7 @@ import 'package:gamer_updater/widgets/game_card.dart';
class NewGameCard extends StatelessWidget { class NewGameCard extends StatelessWidget {
final Function(Game) onGameCreated; final Function(Game) onGameCreated;
const NewGameCard({ const NewGameCard({super.key, required this.onGameCreated});
super.key,
required this.onGameCreated,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {

View File

@@ -6,6 +6,10 @@
#include "generated_plugin_registrant.h" #include "generated_plugin_registrant.h"
#include <file_selector_linux/file_selector_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) { void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
file_selector_plugin_register_with_registrar(file_selector_linux_registrar);
} }

View File

@@ -3,6 +3,7 @@
# #
list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_PLUGIN_LIST
file_selector_linux
) )
list(APPEND FLUTTER_FFI_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST

View File

@@ -5,6 +5,8 @@
import FlutterMacOS import FlutterMacOS
import Foundation import Foundation
import file_selector_macos
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
} }

View File

@@ -41,6 +41,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.19.1" version: "1.19.1"
cross_file:
dependency: transitive
description:
name: cross_file
sha256: "7caf6a750a0c04effbb52a676dce9a4a592e10ad35c34d6d2d0e4811160d5670"
url: "https://pub.dev"
source: hosted
version: "0.3.4+2"
cupertino_icons: cupertino_icons:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -49,14 +57,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.8" version: "1.0.8"
dart_rss:
dependency: "direct main"
description:
name: dart_rss
sha256: "73539d4b7153b47beef8b51763ca55dcb6fc0bb412b29e0f5e74e93fabfd1ac6"
url: "https://pub.dev"
source: hosted
version: "3.0.3"
fake_async: fake_async:
dependency: transitive dependency: transitive
description: description:
@@ -73,6 +73,38 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.4" version: "2.1.4"
file_selector_linux:
dependency: transitive
description:
name: file_selector_linux
sha256: "54cbbd957e1156d29548c7d9b9ec0c0ebb6de0a90452198683a7d23aed617a33"
url: "https://pub.dev"
source: hosted
version: "0.9.3+2"
file_selector_macos:
dependency: transitive
description:
name: file_selector_macos
sha256: "271ab9986df0c135d45c3cdb6bd0faa5db6f4976d3e4b437cf7d0f258d941bfc"
url: "https://pub.dev"
source: hosted
version: "0.9.4+2"
file_selector_platform_interface:
dependency: transitive
description:
name: file_selector_platform_interface
sha256: a3994c26f10378a039faa11de174d7b78eb8f79e4dd0af2a451410c1a5c3f66b
url: "https://pub.dev"
source: hosted
version: "2.6.2"
file_selector_windows:
dependency: transitive
description:
name: file_selector_windows
sha256: "320fcfb6f33caa90f0b58380489fc5ac05d99ee94b61aa96ec2bff0ba81d3c2b"
url: "https://pub.dev"
source: hosted
version: "0.9.3+4"
flutter: flutter:
dependency: "direct main" dependency: "direct main"
description: flutter description: flutter
@@ -86,11 +118,24 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "5.0.0" version: "5.0.0"
flutter_plugin_android_lifecycle:
dependency: transitive
description:
name: flutter_plugin_android_lifecycle
sha256: "615a505aef59b151b46bbeef55b36ce2b6ed299d160c51d84281946f0aa0ce0e"
url: "https://pub.dev"
source: hosted
version: "2.0.24"
flutter_test: flutter_test:
dependency: "direct dev" dependency: "direct dev"
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
flutter_web_plugins:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
http: http:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -107,14 +152,70 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.1.2" version: "4.1.2"
intl: image_picker:
dependency: transitive dependency: "direct main"
description: description:
name: intl name: image_picker
sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf sha256: "021834d9c0c3de46bf0fe40341fa07168407f694d9b2bb18d532dc1261867f7a"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.19.0" version: "1.1.2"
image_picker_android:
dependency: transitive
description:
name: image_picker_android
sha256: "82652a75e3dd667a91187769a6a2cc81bd8c111bbead698d8e938d2b63e5e89a"
url: "https://pub.dev"
source: hosted
version: "0.8.12+21"
image_picker_for_web:
dependency: transitive
description:
name: image_picker_for_web
sha256: "717eb042ab08c40767684327be06a5d8dbb341fe791d514e4b92c7bbe1b7bb83"
url: "https://pub.dev"
source: hosted
version: "3.0.6"
image_picker_ios:
dependency: transitive
description:
name: image_picker_ios
sha256: "05da758e67bc7839e886b3959848aa6b44ff123ab4b28f67891008afe8ef9100"
url: "https://pub.dev"
source: hosted
version: "0.8.12+2"
image_picker_linux:
dependency: transitive
description:
name: image_picker_linux
sha256: "4ed1d9bb36f7cd60aa6e6cd479779cc56a4cb4e4de8f49d487b1aaad831300fa"
url: "https://pub.dev"
source: hosted
version: "0.2.1+1"
image_picker_macos:
dependency: transitive
description:
name: image_picker_macos
sha256: "1b90ebbd9dcf98fb6c1d01427e49a55bd96b5d67b8c67cf955d60a5de74207c1"
url: "https://pub.dev"
source: hosted
version: "0.2.1+2"
image_picker_platform_interface:
dependency: transitive
description:
name: image_picker_platform_interface
sha256: "886d57f0be73c4b140004e78b9f28a8914a09e50c2d816bdd0520051a71236a0"
url: "https://pub.dev"
source: hosted
version: "2.10.1"
image_picker_windows:
dependency: transitive
description:
name: image_picker_windows
sha256: "6ad07afc4eb1bc25f3a01084d28520496c4a3bb0cb13685435838167c9dcedeb"
url: "https://pub.dev"
source: hosted
version: "0.2.1+1"
leak_tracker: leak_tracker:
dependency: transitive dependency: transitive
description: description:
@@ -171,6 +272,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.16.0" version: "1.16.0"
mime:
dependency: transitive
description:
name: mime
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
url: "https://pub.dev"
source: hosted
version: "2.0.0"
path: path:
dependency: transitive dependency: transitive
description: description:
@@ -179,14 +288,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.9.1" version: "1.9.1"
petitparser: plugin_platform_interface:
dependency: transitive dependency: transitive
description: description:
name: petitparser name: plugin_platform_interface
sha256: "07c8f0b1913bcde1ff0d26e57ace2f3012ccbf2b204e070290dad3bb22797646" sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.1.0" version: "2.1.8"
sky_engine: sky_engine:
dependency: transitive dependency: transitive
description: flutter description: flutter
@@ -304,14 +413,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.0" version: "1.1.0"
xml:
dependency: transitive
description:
name: xml
sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226
url: "https://pub.dev"
source: hosted
version: "6.5.0"
sdks: sdks:
dart: ">=3.7.0 <4.0.0" dart: ">=3.7.0 <4.0.0"
flutter: ">=3.18.0-18.0.pre.54" flutter: ">=3.24.0"

View File

@@ -36,7 +36,7 @@ dependencies:
cupertino_icons: ^1.0.8 cupertino_icons: ^1.0.8
sqflite_common_ffi: ^2.3.5 sqflite_common_ffi: ^2.3.5
http: ^1.3.0 http: ^1.3.0
dart_rss: ^3.0.3 image_picker: ^1.0.7
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:

62
release.sh Normal file
View File

@@ -0,0 +1,62 @@
#!/bin/bash
# Determine the tag
echo "Figuring out the tag..."
TAG=$(git describe --tags --exact-match 2>/dev/null || echo "")
if [ -z "$TAG" ]; then
# Get the latest tag
LATEST_TAG=$(git describe --tags $(git rev-list --tags --max-count=1))
# Increment the patch version
IFS='.' read -r -a VERSION_PARTS <<< "$LATEST_TAG"
VERSION_PARTS[2]=$((VERSION_PARTS[2]+1))
TAG="${VERSION_PARTS[0]}.${VERSION_PARTS[1]}.${VERSION_PARTS[2]}"
# Create a new tag
git tag $TAG
git push origin $TAG
fi
echo "Tag: $TAG"
# Build the application
echo "Building the thing..."
flutter build windows --release
flutter build apk --release
echo "Creating a release..."
TOKEN="$GITEA_API_KEY"
GITEA="https://git.site.quack-lab.dev"
REPO="dave/flutter-gamer-updater"
ZIP="gamer-updater-${TAG}.zip"
APK="gamer-updater-${TAG}.apk"
# Create a release
RELEASE_RESPONSE=$(curl -s -X POST \
-H "Authorization: token $TOKEN" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-d '{
"tag_name": "'"$TAG"'",
"name": "'"$TAG"'",
"draft": false,
"prerelease": false
}' \
$GITEA/api/v1/repos/$REPO/releases)
# Extract the release ID
echo $RELEASE_RESPONSE
RELEASE_ID=$(echo $RELEASE_RESPONSE | awk -F'"id":' '{print $2+0; exit}')
echo "Release ID: $RELEASE_ID"
echo "Uploading the things..."
WINRELEASE="./build/windows/x64/runner/Release/"
7z a $WINRELEASE/$ZIP $WINRELEASE/*
curl -X POST \
-H "Authorization: token $TOKEN" \
-F "attachment=@$WINRELEASE/$ZIP" \
"$GITEA/api/v1/repos/$REPO/releases/${RELEASE_ID}/assets?name=$ZIP"
rm $WINRELEASE/$ZIP
ANDROIDRELEASE="./build/app/outputs/flutter-apk/"
mv $ANDROIDRELEASE/app-release.apk $ANDROIDRELEASE/$APK
curl -X POST \
-H "Authorization: token $TOKEN" \
-F "attachment=@$ANDROIDRELEASE/$APK" \
"$GITEA/api/v1/repos/$REPO/releases/${RELEASE_ID}/assets?name=$APK"

View File

@@ -6,6 +6,9 @@
#include "generated_plugin_registrant.h" #include "generated_plugin_registrant.h"
#include <file_selector_windows/file_selector_windows.h>
void RegisterPlugins(flutter::PluginRegistry* registry) { void RegisterPlugins(flutter::PluginRegistry* registry) {
FileSelectorWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FileSelectorWindows"));
} }

View File

@@ -3,6 +3,7 @@
# #
list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_PLUGIN_LIST
file_selector_windows
) )
list(APPEND FLUTTER_FFI_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST