From 7099f7471a5f10824abc796a1a0ddd61f709554f Mon Sep 17 00:00:00 2001 From: dan63047 Date: Tue, 6 Feb 2024 23:38:52 +0300 Subject: [PATCH] TetrioService now should eat less ram Also app now checks if valid nickname was entered in "Your TETR.IO account" dialog. No need to paste userID, app will do it by itself. --- lib/gen/strings.g.dart | 18 +++--- lib/services/tetrio_crud.dart | 93 +++++++++++---------------- lib/views/main_view.dart | 7 +- lib/views/settings_view.dart | 24 ++++++- lib/views/tracked_players_view.dart | 13 ++-- res/i18n/strings.i18n.json | 4 +- res/i18n/strings_ru.i18n.json | 4 +- res/tetrio_badges/twc23_honorary.png | Bin 0 -> 7267 bytes 8 files changed, 81 insertions(+), 82 deletions(-) create mode 100644 res/tetrio_badges/twc23_honorary.png diff --git a/lib/gen/strings.g.dart b/lib/gen/strings.g.dart index 1e105df..c2eb46b 100644 --- a/lib/gen/strings.g.dart +++ b/lib/gen/strings.g.dart @@ -6,7 +6,7 @@ /// Locales: 2 /// Strings: 1016 (508 per locale) /// -/// Built on 2024-02-03 at 12:49 UTC +/// Built on 2024-02-06 at 20:25 UTC // coverage:ignore-file // ignore_for_file: type=lint @@ -224,8 +224,8 @@ class Translations implements BaseTranslations { String get importCancelled => 'Operation was cancelled'; String get importSuccess => 'Import successful'; String get yourID => 'Your TETR.IO account'; - String get yourIDAlertTitle => 'Your TETR.IO account nickname or ID'; - String get yourIDText => 'Every time when app loads, stats of that player will be fetched. Please prefer ID over nickname because nickname can be changed.'; + String get yourIDAlertTitle => 'Your nickname in TETR.IO'; + String get yourIDText => 'When app loads, it will retrieve data for this account'; String get language => 'Language'; String get aboutApp => 'About app'; String aboutAppText({required Object appName, required Object packageName, required Object version, required Object buildNumber}) => '${appName} (${packageName}) Version ${version} Build ${buildNumber}\n\nDeveloped by dan63047\nFormulas provided by kerrmunism\nHistory provided by p1nkl0bst3r\nTETR.IO replay grabber API by szy'; @@ -816,8 +816,8 @@ class _StringsRu implements Translations { @override String get importCancelled => 'Операция была отменена'; @override String get importSuccess => 'Успешно импортировано'; @override String get yourID => 'Ваш аккаунт в TETR.IO'; - @override String get yourIDAlertTitle => 'Никнейм или ID вашего аккаунта в TETR.IO'; - @override String get yourIDText => 'Каждый раз, когда приложение запускается, приложение будет получать статистику этого игрока. Пожалуйста, отдайте предпочтение ID, так как никнейм можно изменить.'; + @override String get yourIDAlertTitle => 'Ваш ник в TETR.IO'; + @override String get yourIDText => 'При запуске приложения оно будет получать статистику этого игрока.'; @override String get language => 'Язык (Language)'; @override String get aboutApp => 'О приложении'; @override String aboutAppText({required Object appName, required Object packageName, required Object version, required Object buildNumber}) => '${appName} (${packageName}) Версия ${version} Сборка ${buildNumber}\n\nРазработал dan63047\nФормулы предоставил kerrmunism\nИсторию предоставляет p1nkl0bst3r\nВозможность скачивать повторы из TETR.IO предоставляет szy'; @@ -1400,8 +1400,8 @@ extension on Translations { case 'importCancelled': return 'Operation was cancelled'; case 'importSuccess': return 'Import successful'; case 'yourID': return 'Your TETR.IO account'; - case 'yourIDAlertTitle': return 'Your TETR.IO account nickname or ID'; - case 'yourIDText': return 'Every time when app loads, stats of that player will be fetched. Please prefer ID over nickname because nickname can be changed.'; + case 'yourIDAlertTitle': return 'Your nickname in TETR.IO'; + case 'yourIDText': return 'When app loads, it will retrieve data for this account'; case 'language': return 'Language'; case 'aboutApp': return 'About app'; case 'aboutAppText': return ({required Object appName, required Object packageName, required Object version, required Object buildNumber}) => '${appName} (${packageName}) Version ${version} Build ${buildNumber}\n\nDeveloped by dan63047\nFormulas provided by kerrmunism\nHistory provided by p1nkl0bst3r\nTETR.IO replay grabber API by szy'; @@ -1918,8 +1918,8 @@ extension on _StringsRu { case 'importCancelled': return 'Операция была отменена'; case 'importSuccess': return 'Успешно импортировано'; case 'yourID': return 'Ваш аккаунт в TETR.IO'; - case 'yourIDAlertTitle': return 'Никнейм или ID вашего аккаунта в TETR.IO'; - case 'yourIDText': return 'Каждый раз, когда приложение запускается, приложение будет получать статистику этого игрока. Пожалуйста, отдайте предпочтение ID, так как никнейм можно изменить.'; + case 'yourIDAlertTitle': return 'Ваш ник в TETR.IO'; + case 'yourIDText': return 'При запуске приложения оно будет получать статистику этого игрока.'; case 'language': return 'Язык (Language)'; case 'aboutApp': return 'О приложении'; case 'aboutAppText': return ({required Object appName, required Object packageName, required Object version, required Object buildNumber}) => '${appName} (${packageName}) Версия ${version} Сборка ${buildNumber}\n\nРазработал dan63047\nФормулы предоставил kerrmunism\nИсторию предоставляет p1nkl0bst3r\nВозможность скачивать повторы из TETR.IO предоставляет szy'; diff --git a/lib/services/tetrio_crud.dart b/lib/services/tetrio_crud.dart index 7f8843b..b5bf668 100644 --- a/lib/services/tetrio_crud.dart +++ b/lib/services/tetrio_crud.dart @@ -27,7 +27,7 @@ const String endContext2 = "endContext2"; const String statesCol = "jsonStates"; const String player1id = "player1id"; const String player2id = "player2id"; -/// Table, that store players data, their stats and some moments of time +/// Table, that store players data, their stats at some moments of time const String createTetrioUsersTable = ''' CREATE TABLE IF NOT EXISTS "tetrioUsers" ( "id" TEXT UNIQUE, @@ -66,7 +66,7 @@ const String createTetrioTLReplayStats = ''' '''; class TetrioService extends DB { - Map> _players = {}; + final Map _players = {}; // I'm trying to send as less requests, as possible, so i'm caching the results of those requests. // Usually those maps looks like this: {"cached_until_unix_milliseconds": Object} @@ -82,9 +82,9 @@ class TetrioService extends DB { /// We should have only one instanse of this service static final TetrioService _shared = TetrioService._sharedInstance(); factory TetrioService() => _shared; - late final StreamController>> _tetrioStreamController; + late final StreamController> _tetrioStreamController; TetrioService._sharedInstance() { - _tetrioStreamController = StreamController>>.broadcast(onListen: () { + _tetrioStreamController = StreamController>.broadcast(onListen: () { _tetrioStreamController.sink.add(_players); }); } @@ -95,17 +95,15 @@ class TetrioService extends DB { await _loadPlayers(); } - Stream>> get allPlayers => _tetrioStreamController.stream; + Stream> get allPlayers => _tetrioStreamController.stream; /// Loading and sending to the stream everyone. Future _loadPlayers() async { - final allPlayers = await getAllPlayers(); - try{ - _players = allPlayers.toList().first; // ??? - }catch (e){ - developer.log("_loadPlayers: allPlayers.toList().first did oopsie", name: "services/tetrio_crud", error: e); - _players = {}; + final allPlayers = await getAllPlayerToTrack(); + for (var element in allPlayers) { + _players[element] = await getNicknameByID(element); } + developer.log("_loadPlayers: $_players", name: "services/tetrio_crud"); _tetrioStreamController.add(_players); } @@ -128,7 +126,10 @@ class TetrioService extends DB { Future getNicknameByID(String id) async { if (id.length <= 16) return id; // nicknames can be up to 16 symbols in length, that's how i'm differentiate nickname from ids try{ - return await getPlayer(id).then((value) => value.last.username); + await ensureDbIsOpen(); + final db = getDatabaseOrThrow(); + var request = await db.query(tetrioUsersTable, limit: 1, where: '$idCol = ?', whereArgs: [id.toLowerCase()]); + return request.first[nickCol] as String; } catch (e){ return await fetchPlayer(id).then((value) => value.username); } @@ -350,16 +351,8 @@ class TetrioService extends DB { // trying to dump it to local DB await ensureDbIsOpen(); final db = getDatabaseOrThrow(); - late List states; - try{ - // checking if tetra stats aware about that player TODO: is it necessary? - states = _players[id]!; - }catch(e){ - // if somehow not - create it - var player = await fetchPlayer(id); - await createPlayer(player); - states = _players[id]!; - } + List states = await getPlayer(id); + if (states.isEmpty) await createPlayer(history.first); states.insertAll(0, history.reversed); final Map statesJson = {}; for (var e in states) { // making one big json out of this list @@ -367,7 +360,6 @@ class TetrioService extends DB { } // and putting it to local DB await db.update(tetrioUsersTable, {idCol: id, nickCol: nick, statesCol: jsonEncode(statesJson)}, where: '$idCol = ?', whereArgs: [id]); - _tetrioStreamController.add(_players); return history; case 404: developer.log("fetchTLHistory: Probably, history doesn't exist", name: "services/tetrio_crud", error: response.statusCode); @@ -732,7 +724,7 @@ class TetrioService extends DB { await db.delete(tetrioTLReplayStatsTable, where: '$idCol = ?', whereArgs: [rID]); } - /// Retrieves Blitz, 40 Lines and Zen records for a given [playerID] from Tetra Channel api. Returns Map, which contains user id (`user`), + /// Retrieves Blitz, 40 Lines and Zen records for a given [userID] from Tetra Channel api. Returns Map, which contains user id (`user`), /// Blitz (`blitz`) and 40 Lines (`sprint`) record objects and Zen object (`zen`). Throws an exception if fails to retrieve. Future> fetchRecords(String userID) async { try{ @@ -811,9 +803,7 @@ class TetrioService extends DB { // converting to json and store final Map statesJson = {(tetrioPlayer.state.millisecondsSinceEpoch ~/ 1000).toString(): tetrioPlayer.toJson()}; db.insert(tetrioUsersTable, {idCol: tetrioPlayer.userId, nickCol: tetrioPlayer.username, statesCol: jsonEncode(statesJson)}); - _players.addEntries({ - tetrioPlayer.userId: [tetrioPlayer] - }.entries); + _players.addEntries({tetrioPlayer.userId: tetrioPlayer.username}.entries); _tetrioStreamController.add(_players); } @@ -841,7 +831,6 @@ class TetrioService extends DB { await ensureDbIsOpen(); final db = getDatabaseOrThrow(); final players = await db.query(tetrioUsersToTrackTable); - developer.log("getAllPlayerToTrack: $players", name: "services/tetrio_crud"); return players.map((noteRow) => noteRow["id"].toString()); } @@ -853,25 +842,22 @@ class TetrioService extends DB { if (deletedPlayer != 1) { throw CouldNotDeletePlayer(); } else { - // _players.removeWhere((key, value) => key == id); - // _tetrioStreamController.add(_players); + _players.removeWhere((key, value) => key == id); + _tetrioStreamController.add(_players); } } /// Saves state (which is [tetrioPlayer]) to the local database. Future storeState(TetrioPlayer tetrioPlayer) async { - await ensureDbIsOpen(); - final db = getDatabaseOrThrow(); - late List states; - try { // retrieveing previous states - states = _players[tetrioPlayer.userId]!; - } catch (e) { // nothing found - player not exist - create them - await createPlayer(tetrioPlayer); - states = await getPlayer(tetrioPlayer.userId); + // if tetrio player doesn't have entry in database - just calling different function + List states = await getPlayer(tetrioPlayer.userId); + if (states.isEmpty) { + await createPlayer(tetrioPlayer); + return; } // we not going to add state, that is same, as the previous - bool test = _players[tetrioPlayer.userId]!.last.isSameState(tetrioPlayer); + bool test = states.last.isSameState(tetrioPlayer); if (test == false) states.add(tetrioPlayer); // Making map of the states @@ -880,21 +866,21 @@ class TetrioService extends DB { // Saving in format: {"unix_seconds": json_of_state} statesJson.addEntries({(e.state.millisecondsSinceEpoch ~/ 1000).toString(): e.toJson()}.entries); } + // Rewrite our database + await ensureDbIsOpen(); + final db = getDatabaseOrThrow(); await db.update(tetrioUsersTable, {idCol: tetrioPlayer.userId, nickCol: tetrioPlayer.username, statesCol: jsonEncode(statesJson)}, where: '$idCol = ?', whereArgs: [tetrioPlayer.userId]); - _players[tetrioPlayer.userId]!.add(tetrioPlayer); - _tetrioStreamController.add(_players); } /// Remove state (which is [tetrioPlayer]) from the local database Future deleteState(TetrioPlayer tetrioPlayer) async { await ensureDbIsOpen(); final db = getDatabaseOrThrow(); - late List states; + List states = await getPlayer(tetrioPlayer.userId); // removing state from map that contain every state of each user - _players[tetrioPlayer.userId]!.removeWhere((element) => element.state == tetrioPlayer.state); - states = _players[tetrioPlayer.userId]!; + states.removeWhere((element) => element.state == tetrioPlayer.state); // Making map of the states (without deleted one) final Map statesJson = {}; @@ -904,7 +890,6 @@ class TetrioService extends DB { // Rewriting database entry with new json await db.update(tetrioUsersTable, {idCol: tetrioPlayer.userId, nickCol: tetrioPlayer.username, statesCol: jsonEncode(statesJson)}, where: '$idCol = ?', whereArgs: [tetrioPlayer.userId]); - _players[tetrioPlayer.userId]!.add(tetrioPlayer); _tetrioStreamController.add(_players); } @@ -924,7 +909,7 @@ class TetrioService extends DB { rawStates.forEach((k, v) => states.add(TetrioPlayer.fromJson(v, DateTime.fromMillisecondsSinceEpoch(int.parse(k) * 1000), id, results.first[nickCol] as String))); // updating the stream _players.removeWhere((key, value) => key == id); - _players.addEntries({states.last.userId: states}.entries); + _players.addEntries({states.last.userId: states.last.username}.entries); _tetrioStreamController.add(_players); return states; } @@ -1034,20 +1019,18 @@ class TetrioService extends DB { } } - /// Basucally, retrieves whole [tetrioUsersTable] and do stupud things idk - /// Returns god knows what. TODO: Rewrite this shit - Future>>> getAllPlayers() async { + /// Retrieves whole [tetrioUsersTable] and returns Map with [TetrioPlayer] objects of everyone in database + Future>> getAllPlayers() async { await ensureDbIsOpen(); final db = getDatabaseOrThrow(); final players = await db.query(tetrioUsersTable); Map> data = {}; - return players.map((row) { - // what the fuck am i doing here? - var test = json.decode(row['jsonStates'] as String); + for (var entry in players){ + var test = json.decode(entry['jsonStates'] as String); List states = []; - test.forEach((k, v) => states.add(TetrioPlayer.fromJson(v, DateTime.fromMillisecondsSinceEpoch(int.parse(k) * 1000), row[idCol] as String, row[nickCol] as String))); + test.forEach((k, v) => states.add(TetrioPlayer.fromJson(v, DateTime.fromMillisecondsSinceEpoch(int.parse(k) * 1000), entry[idCol] as String, entry[nickCol] as String))); data.addEntries({states.last.userId: states}.entries); - return data; - }); + } + return data; } } diff --git a/lib/views/main_view.dart b/lib/views/main_view.dart index af5eb19..66d0d54 100644 --- a/lib/views/main_view.dart +++ b/lib/views/main_view.dart @@ -494,8 +494,9 @@ class _NavDrawerState extends State { case ConnectionState.waiting: case ConnectionState.active: final allPlayers = (snapshot.data != null) - ? snapshot.data as Map> - : >{}; + ? snapshot.data as Map + : {}; + allPlayers.remove(prefs.getString("player") ?? "6098518e3d5155e6ec429cdc"); // player from the home button will be delisted List keys = allPlayers.keys.toList(); return NestedScrollView( headerSliverBuilder: (context, value) { @@ -550,7 +551,7 @@ class _NavDrawerState extends State { itemBuilder: (context, index) { var i = allPlayers.length-1-index; // Last players in this map are most recent ones, they are gonna be shown at the top. return ListTile( - title: Text(allPlayers[keys[i]]?.last.username as String), // Takes last known username from list of states + title: Text(allPlayers[keys[i]]??keys[i]), // Takes last known username from list of states onTap: () { widget.changePlayer(keys[i]); // changes to chosen player Navigator.of(context).pop(); // and closes itself. diff --git a/lib/views/settings_view.dart b/lib/views/settings_view.dart index a0a0807..6edb8b9 100644 --- a/lib/views/settings_view.dart +++ b/lib/views/settings_view.dart @@ -1,4 +1,5 @@ import 'dart:io'; +import 'package:tetra_stats/data_objects/tetrio.dart'; import 'package:tetra_stats/main.dart' show packageInfo; import 'package:file_selector/file_selector.dart'; import 'package:file_picker/file_picker.dart'; @@ -66,6 +67,11 @@ class SettingsState extends State { await _setDefaultNickname(player); } + Future _removePlayer() async { + await prefs.remove('player'); + await _setDefaultNickname("dan63047"); + } + @override Widget build(BuildContext context) { final t = Translations.of(context); @@ -212,9 +218,21 @@ class SettingsState extends State { ), TextButton( child: Text(t.popupActions.submit), - onPressed: () { - _setPlayer(_playertext.text.toLowerCase().trim()); - Navigator.of(context).pop(); + onPressed: () async { + if (_playertext.text.isEmpty) { + _removePlayer(); + Navigator.of(context).pop(); + return; + } + late TetrioPlayer user; + try{ + user = await teto.fetchPlayer(_playertext.text.toLowerCase().trim()); + }on Exception{ + if (context.mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(t.errors.noSuchUser))); + return; + } + _setPlayer(user.userId); + if (context.mounted) Navigator.of(context).pop(); setState(() {}); }, ) diff --git a/lib/views/tracked_players_view.dart b/lib/views/tracked_players_view.dart index 1916dbb..2b11976 100644 --- a/lib/views/tracked_players_view.dart +++ b/lib/views/tracked_players_view.dart @@ -70,14 +70,15 @@ class TrackedPlayersState extends State { ), backgroundColor: Colors.black, body: SafeArea( - child: StreamBuilder( - stream: teto.allPlayers, + child: FutureBuilder( + future: teto.getAllPlayers(), builder: (context, snapshot) { switch (snapshot.connectionState) { case ConnectionState.none: - return const Center(child: Text('none case of StreamBuilder')); case ConnectionState.waiting: case ConnectionState.active: + return const Center(child: CircularProgressIndicator(color: Colors.white)); + case ConnectionState.done: final allPlayers = (snapshot.data != null) ? snapshot.data as Map> : >{}; List keys = allPlayers.keys.toList(); return NestedScrollView( @@ -114,7 +115,7 @@ class TrackedPlayersState extends State { icon: const Icon(Icons.delete_forever), onPressed: () { String nn = allPlayers[keys[index]]!.last.username; - teto.deletePlayer(keys[index]); + setState(() {teto.deletePlayer(keys[index]);}); ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(t.trackedPlayersStatesDeleted(nickname: nn)))); }, ), @@ -128,10 +129,6 @@ class TrackedPlayersState extends State { }, ); })); - case ConnectionState.done: - return const Center( - child: Text('done case of StreamBuilder', - style: TextStyle(fontFamily: "Eurostile Round Extended", fontSize: 42), textAlign: TextAlign.center)); } })), ); diff --git a/res/i18n/strings.i18n.json b/res/i18n/strings.i18n.json index 92042ac..d063400 100644 --- a/res/i18n/strings.i18n.json +++ b/res/i18n/strings.i18n.json @@ -89,8 +89,8 @@ "importCancelled": "Operation was cancelled", "importSuccess": "Import successful", "yourID": "Your TETR.IO account", - "yourIDAlertTitle": "Your TETR.IO account nickname or ID", - "yourIDText": "Every time when app loads, stats of that player will be fetched. Please prefer ID over nickname because nickname can be changed.", + "yourIDAlertTitle": "Your nickname in TETR.IO", + "yourIDText": "When app loads, it will retrieve data for this account", "language": "Language", "aboutApp": "About app", "aboutAppText": "${appName} (${packageName}) Version ${version} Build ${buildNumber}\n\nDeveloped by dan63047\nFormulas provided by kerrmunism\nHistory provided by p1nkl0bst3r\nTETR.IO replay grabber API by szy", diff --git a/res/i18n/strings_ru.i18n.json b/res/i18n/strings_ru.i18n.json index ebed9c7..054c6cd 100644 --- a/res/i18n/strings_ru.i18n.json +++ b/res/i18n/strings_ru.i18n.json @@ -89,8 +89,8 @@ "importCancelled": "Операция была отменена", "importSuccess": "Успешно импортировано", "yourID": "Ваш аккаунт в TETR.IO", - "yourIDAlertTitle": "Никнейм или ID вашего аккаунта в TETR.IO", - "yourIDText": "Каждый раз, когда приложение запускается, приложение будет получать статистику этого игрока. Пожалуйста, отдайте предпочтение ID, так как никнейм можно изменить.", + "yourIDAlertTitle": "Ваш ник в TETR.IO", + "yourIDText": "При запуске приложения оно будет получать статистику этого игрока.", "language": "Язык (Language)", "aboutApp": "О приложении", "aboutAppText": "${appName} (${packageName}) Версия ${version} Сборка ${buildNumber}\n\nРазработал dan63047\nФормулы предоставил kerrmunism\nИсторию предоставляет p1nkl0bst3r\nВозможность скачивать повторы из TETR.IO предоставляет szy", diff --git a/res/tetrio_badges/twc23_honorary.png b/res/tetrio_badges/twc23_honorary.png new file mode 100644 index 0000000000000000000000000000000000000000..469ce26dbf425a5a214cb17cd1add2ff52b5cd9e GIT binary patch literal 7267 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