diff --git a/lib/data_objects/tetra_stats.dart b/lib/data_objects/tetra_stats.dart new file mode 100644 index 0000000..e5af9cf --- /dev/null +++ b/lib/data_objects/tetra_stats.dart @@ -0,0 +1,15 @@ +// p1nkl0bst3r data objects + +class Cutoffs{ + Map tr; + Map glicko; + + Cutoffs(this.tr, this.glicko); +} + +class TopTr{ + String id; + double? tr; + + TopTr(this.id, this.tr); +} \ No newline at end of file diff --git a/lib/data_objects/tetrio.dart b/lib/data_objects/tetrio.dart index 0e300a6..e89b2cf 100644 --- a/lib/data_objects/tetrio.dart +++ b/lib/data_objects/tetrio.dart @@ -265,6 +265,7 @@ class TetrioPlayer { List blitz = []; TetrioZen? zen; Distinguishment? distinguishment; + DateTime? cachedUntil; TetrioPlayer({ required this.userId, @@ -292,11 +293,12 @@ class TetrioPlayer { required this.blitz, this.zen, this.distinguishment, + this.cachedUntil }); double get level => pow((xp / 500), 0.6) + (xp / (5000 + (max(0, xp - 4 * pow(10, 6)) / 5000))) + 1; - TetrioPlayer.fromJson(Map json, DateTime stateTime, String id, String nick) { + TetrioPlayer.fromJson(Map json, DateTime stateTime, String id, String nick, [DateTime? cUntil]) { //developer.log("TetrioPlayer.fromJson $stateTime: $json", name: "data_objects/tetrio"); userId = id; username = nick; @@ -324,6 +326,7 @@ class TetrioPlayer { friendCount = json['friend_count'] ?? 0; badstanding = json['badstanding']; botmaster = json['botmaster']; + cachedUntil = cUntil; } Map toJson() { @@ -869,6 +872,21 @@ class TetraLeagueAlphaStream{ } } +class SingleplayerStream{ + late String userId; + late String type; + late List records; + + SingleplayerStream({required this.userId, required this.records, required this.type}); + + SingleplayerStream.fromJson(List json, String userID, String tp) { + userId = userID; + type = tp; + records = []; + for (var value in json) {records.add(RecordSingle.fromJson(value, null));} + } +} + class TetraLeagueAlphaRecord{ late String replayId; late String ownId; @@ -1113,19 +1131,17 @@ class RecordSingle { late String userId; late String replayId; late String ownId; - late String stream; - DateTime? timestamp; - EndContextSingle? endContext; + late DateTime timestamp; + late EndContextSingle endContext; int? rank; - RecordSingle({required this.userId, required this.replayId, required this.ownId, this.timestamp, this.endContext, this.rank}); + RecordSingle({required this.userId, required this.replayId, required this.ownId, required this.timestamp, required this.endContext, this.rank}); RecordSingle.fromJson(Map json, int? ran) { //developer.log("RecordSingle.fromJson: $json", name: "data_objects/tetrio"); ownId = json['_id']; - endContext = json['endcontext'] != null ? EndContextSingle.fromJson(json['endcontext']) : null; + endContext = EndContextSingle.fromJson(json['endcontext']); replayId = json['replayid']; - stream = json['stream']; timestamp = DateTime.parse(json['ts']); userId = json['user']['_id']; rank = ran; @@ -1134,9 +1150,7 @@ class RecordSingle { Map toJson() { final Map data = {}; data['_id'] = ownId; - if (endContext != null) { - data['endcontext'] = endContext!.toJson(); - } + data['endcontext'] = endContext.toJson(); data['ismulti'] = false; data['replayid'] = replayId; data['ts'] = timestamp; @@ -1164,6 +1178,15 @@ class TetrioZen { } } +class UserRecords{ + String id; + RecordSingle? sprint; + RecordSingle? blitz; + TetrioZen zen; + + UserRecords(this.id, this.sprint, this.blitz, this.zen); +} + class Distinguishment { late String type; String? detail; @@ -1192,18 +1215,28 @@ class Distinguishment { } } -class News { +class News{ late String id; - late String stream; + late List news; + + News(this.id, this.news); + + News.fromJson(Map json, String? userID){ + id = userID != null ? "user_$userID" : json['news'].first['stream']; + news = [for (var entry in json['news']) NewsEntry.fromJson(entry)]; + } +} + +class NewsEntry { + //late String id; do i need it? late String type; late Map data; late DateTime timestamp; - News({required this.type, required this.id, required this.stream, required this.data, required this.timestamp}); + NewsEntry({required this.type, required this.data, required this.timestamp}); - News.fromJson(Map json){ - id = json["_id"]; - stream = json["stream"]; + NewsEntry.fromJson(Map json){ + //id = json["_id"]; type = json["type"]; data = json["data"]; timestamp = DateTime.parse(json['ts']); diff --git a/lib/data_objects/tetrio_multiplayer_replay.dart b/lib/data_objects/tetrio_multiplayer_replay.dart index 406675b..9e5ecbd 100644 --- a/lib/data_objects/tetrio_multiplayer_replay.dart +++ b/lib/data_objects/tetrio_multiplayer_replay.dart @@ -1,4 +1,5 @@ import 'dart:math'; +import 'dart:typed_data'; import 'tetrio.dart'; @@ -57,6 +58,14 @@ class Garbage{ // charsys where??? } } +class RawReplay{ + String id; + Uint8List asBytes; + String asString; + + RawReplay(this.id, this.asBytes, this.asString); +} + class ReplayStats{ late int seed; late int linesCleared; diff --git a/lib/gen/strings.g.dart b/lib/gen/strings.g.dart index eb88122..6b05e47 100644 --- a/lib/gen/strings.g.dart +++ b/lib/gen/strings.g.dart @@ -4,9 +4,9 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 2 -/// Strings: 1144 (572 per locale) +/// Strings: 1182 (591 per locale) /// -/// Built on 2024-05-28 at 20:38 UTC +/// Built on 2024-06-16 at 21:03 UTC // coverage:ignore-file // ignore_for_file: type=lint @@ -157,6 +157,11 @@ class Translations implements BaseTranslations { String get history => 'History'; String get sprint => '40 Lines'; String get blitz => 'Blitz'; + String get recent => 'Recent'; + String get recentRuns => 'Recent runs'; + String blitzScore({required Object p}) => '${p} points'; + String get openSPreplay => 'Open replay in TETR.IO'; + String get downloadSPreplay => 'Download replay'; String get other => 'Other'; String get distinguishment => 'Distinguishment'; String get zen => 'Zen'; @@ -244,14 +249,28 @@ class Translations implements BaseTranslations { 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 updateInBackground => 'Update stats in the background'; + String get updateInBackgroundDescription => 'While Tetra Stats is running, it can update stats of the current player when cache expires'; String get customization => 'Customization'; - String get customizationDescription => 'There is only one toggle, planned to add more settings'; + String get customizationDescription => 'Change appearance of different things in Tetra Stats UI'; + String get oskKagari => 'Osk Kagari gimmick'; + String get oskKagariDescription => 'If on, osk\'s rank on main view will be rendered as :kagari:'; + String get AccentColor => 'Accent color'; + String get AccentColorDescription => 'Almost all interactive UI elements highlighted with this color'; + String get timestamps => 'Timestamps'; + String get timestampsDescription => 'You can choose, in which way timestamps shows time'; + String get timestampsAbsoluteGMT => 'Absolute (GMT)'; + String get timestampsAbsoluteLocalTime => 'Absolute (Your timezone)'; + String get timestampsRelative => 'Relative'; + String get rating => 'Main representation of rating'; + String get ratingDescription => 'TR is not linear, while Glicko does not have boundaries and percentile is volatile'; + String get ratingLBposition => 'LB position'; + String get sheetbotGraphs => 'Sheetbot-like behavior for radar graphs'; + String get sheetbotGraphsDescription => 'If on, points on the graphs can appear on the opposite half of the graph if value is negative'; String get lbStats => 'Show leaderboard based stats'; String get lbStatsDescription => 'That will impact on loading times, but will allow you to see position on LB by stats and comparison with average values'; 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'; - String get oskKagari => 'Osk Kagari gimmick'; - String get oskKagariDescription => 'If on, osk\'s rank on main view will be rendered as :kagari:'; String stateViewTitle({required Object nickname, required Object date}) => '${nickname} account on ${date}'; String statesViewTitle({required Object number, required Object nickname}) => '${number} states of ${nickname} account'; String matchesViewTitle({required Object nickname}) => '${nickname} TL matches'; @@ -833,6 +852,11 @@ class _StringsRu implements Translations { @override String get history => 'История'; @override String get sprint => '40 линий'; @override String get blitz => 'Блиц'; + @override String get recent => 'Недавно'; + @override String get recentRuns => 'Недавние'; + @override String blitzScore({required Object p}) => '${p} очков'; + @override String get openSPreplay => 'Открыть повтор в TETR.IO'; + @override String get downloadSPreplay => 'Скачать повтор'; @override String get other => 'Другое'; @override String get distinguishment => 'Заслуга'; @override String get zen => 'Дзен'; @@ -920,14 +944,28 @@ class _StringsRu implements Translations { @override String get yourIDAlertTitle => 'Ваш ник в TETR.IO'; @override String get yourIDText => 'При запуске приложения оно будет получать статистику этого игрока.'; @override String get language => 'Язык (Language)'; + @override String get updateInBackground => 'Обновлять статистику в фоне'; + @override String get updateInBackgroundDescription => 'Пока Tetra Stats работает, он может обновлять статистику самостоятельно когда кеш истекает'; @override String get customization => 'Кастомизация'; - @override String get customizationDescription => 'Здесь только один переключатель, в планах добавить больше'; + @override String get customizationDescription => 'Измените внешний вид пользовательского интерфейса Tetra Stats'; + @override String get oskKagari => '"Оск Кагари" прикол'; + @override String get oskKagariDescription => 'Если включено, вместо настоящего ранга оска будет рендерится :kagari:'; + @override String get AccentColor => 'Цветовой акцент'; + @override String get AccentColorDescription => 'Почти все интерактивные элементы пользовательского интерфейса окрашены в этот цвет'; + @override String get timestamps => 'Метки времени'; + @override String get timestampsDescription => 'Вы можете выбрать, каким образом метки времени показывают время'; + @override String get timestampsAbsoluteGMT => 'Абсолютные (GMT)'; + @override String get timestampsAbsoluteLocalTime => 'Абсолютные (Ваш часовой пояс)'; + @override String get timestampsRelative => 'Относительные'; + @override String get rating => 'Основное представление рейтинга'; + @override String get ratingDescription => 'TR нелинеен, тогда как Glicko не имеет границ, а положение в таблице лидеров волатильно'; + @override String get ratingLBposition => 'Позиция в рейтинге'; + @override String get sheetbotGraphs => 'Графики-радары как у sheetBot'; + @override String get sheetbotGraphsDescription => 'Если включено, точки на графике могут появляться на противоположной стороне графика если значение отрицательное'; @override String get lbStats => 'Показывать статистику, основанную на рейтинговой таблице'; @override String get lbStatsDescription => 'Это повлияет на время загрузки, но позволит видеть положение в рейтинге и сравнение со средними значениями по рангу по каждой стате'; @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'; - @override String get oskKagari => '"Оск Кагари" прикол'; - @override String get oskKagariDescription => 'Если включено, вместо настоящего ранга оска будет рендерится :kagari:'; @override String stateViewTitle({required Object nickname, required Object date}) => 'Аккаунт ${nickname} ${date}'; @override String statesViewTitle({required Object number, required Object nickname}) => '${number} состояний аккаунта ${nickname}'; @override String matchesViewTitle({required Object nickname}) => 'Матчи аккаунта ${nickname}'; @@ -1489,6 +1527,11 @@ extension on Translations { case 'history': return 'History'; case 'sprint': return '40 Lines'; case 'blitz': return 'Blitz'; + case 'recent': return 'Recent'; + case 'recentRuns': return 'Recent runs'; + case 'blitzScore': return ({required Object p}) => '${p} points'; + case 'openSPreplay': return 'Open replay in TETR.IO'; + case 'downloadSPreplay': return 'Download replay'; case 'other': return 'Other'; case 'distinguishment': return 'Distinguishment'; case 'zen': return 'Zen'; @@ -1588,14 +1631,28 @@ extension on Translations { 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 'updateInBackground': return 'Update stats in the background'; + case 'updateInBackgroundDescription': return 'While Tetra Stats is running, it can update stats of the current player when cache expires'; case 'customization': return 'Customization'; - case 'customizationDescription': return 'There is only one toggle, planned to add more settings'; + case 'customizationDescription': return 'Change appearance of different things in Tetra Stats UI'; + case 'oskKagari': return 'Osk Kagari gimmick'; + case 'oskKagariDescription': return 'If on, osk\'s rank on main view will be rendered as :kagari:'; + case 'AccentColor': return 'Accent color'; + case 'AccentColorDescription': return 'Almost all interactive UI elements highlighted with this color'; + case 'timestamps': return 'Timestamps'; + case 'timestampsDescription': return 'You can choose, in which way timestamps shows time'; + case 'timestampsAbsoluteGMT': return 'Absolute (GMT)'; + case 'timestampsAbsoluteLocalTime': return 'Absolute (Your timezone)'; + case 'timestampsRelative': return 'Relative'; + case 'rating': return 'Main representation of rating'; + case 'ratingDescription': return 'TR is not linear, while Glicko does not have boundaries and percentile is volatile'; + case 'ratingLBposition': return 'LB position'; + case 'sheetbotGraphs': return 'Sheetbot-like behavior for radar graphs'; + case 'sheetbotGraphsDescription': return 'If on, points on the graphs can appear on the opposite half of the graph if value is negative'; case 'lbStats': return 'Show leaderboard based stats'; case 'lbStatsDescription': return 'That will impact on loading times, but will allow you to see position on LB by stats and comparison with average values'; 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'; - case 'oskKagari': return 'Osk Kagari gimmick'; - case 'oskKagariDescription': return 'If on, osk\'s rank on main view will be rendered as :kagari:'; case 'stateViewTitle': return ({required Object nickname, required Object date}) => '${nickname} account on ${date}'; case 'statesViewTitle': return ({required Object number, required Object nickname}) => '${number} states of ${nickname} account'; case 'matchesViewTitle': return ({required Object nickname}) => '${nickname} TL matches'; @@ -2081,6 +2138,11 @@ extension on _StringsRu { case 'history': return 'История'; case 'sprint': return '40 линий'; case 'blitz': return 'Блиц'; + case 'recent': return 'Недавно'; + case 'recentRuns': return 'Недавние'; + case 'blitzScore': return ({required Object p}) => '${p} очков'; + case 'openSPreplay': return 'Открыть повтор в TETR.IO'; + case 'downloadSPreplay': return 'Скачать повтор'; case 'other': return 'Другое'; case 'distinguishment': return 'Заслуга'; case 'zen': return 'Дзен'; @@ -2180,14 +2242,28 @@ extension on _StringsRu { case 'yourIDAlertTitle': return 'Ваш ник в TETR.IO'; case 'yourIDText': return 'При запуске приложения оно будет получать статистику этого игрока.'; case 'language': return 'Язык (Language)'; + case 'updateInBackground': return 'Обновлять статистику в фоне'; + case 'updateInBackgroundDescription': return 'Пока Tetra Stats работает, он может обновлять статистику самостоятельно когда кеш истекает'; case 'customization': return 'Кастомизация'; - case 'customizationDescription': return 'Здесь только один переключатель, в планах добавить больше'; + case 'customizationDescription': return 'Измените внешний вид пользовательского интерфейса Tetra Stats'; + case 'oskKagari': return '"Оск Кагари" прикол'; + case 'oskKagariDescription': return 'Если включено, вместо настоящего ранга оска будет рендерится :kagari:'; + case 'AccentColor': return 'Цветовой акцент'; + case 'AccentColorDescription': return 'Почти все интерактивные элементы пользовательского интерфейса окрашены в этот цвет'; + case 'timestamps': return 'Метки времени'; + case 'timestampsDescription': return 'Вы можете выбрать, каким образом метки времени показывают время'; + case 'timestampsAbsoluteGMT': return 'Абсолютные (GMT)'; + case 'timestampsAbsoluteLocalTime': return 'Абсолютные (Ваш часовой пояс)'; + case 'timestampsRelative': return 'Относительные'; + case 'rating': return 'Основное представление рейтинга'; + case 'ratingDescription': return 'TR нелинеен, тогда как Glicko не имеет границ, а положение в таблице лидеров волатильно'; + case 'ratingLBposition': return 'Позиция в рейтинге'; + case 'sheetbotGraphs': return 'Графики-радары как у sheetBot'; + case 'sheetbotGraphsDescription': return 'Если включено, точки на графике могут появляться на противоположной стороне графика если значение отрицательное'; case 'lbStats': return 'Показывать статистику, основанную на рейтинговой таблице'; case 'lbStatsDescription': return 'Это повлияет на время загрузки, но позволит видеть положение в рейтинге и сравнение со средними значениями по рангу по каждой стате'; 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'; - case 'oskKagari': return '"Оск Кагари" прикол'; - case 'oskKagariDescription': return 'Если включено, вместо настоящего ранга оска будет рендерится :kagari:'; case 'stateViewTitle': return ({required Object nickname, required Object date}) => 'Аккаунт ${nickname} ${date}'; case 'statesViewTitle': return ({required Object number, required Object nickname}) => '${number} состояний аккаунта ${nickname}'; case 'matchesViewTitle': return ({required Object nickname}) => 'Матчи аккаунта ${nickname}'; diff --git a/lib/main.dart b/lib/main.dart index f1d26fb..162f783 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,10 +1,16 @@ +import 'dart:async'; import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'dart:developer' as developer; import 'package:package_info_plus/package_info_plus.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:tetra_stats/services/tetrio_crud.dart'; import 'package:tetra_stats/views/customization_view.dart'; +import 'package:tetra_stats/views/ranks_averages_view.dart'; +import 'package:tetra_stats/views/sprint_and_blitz_averages.dart'; +import 'package:tetra_stats/views/tl_leaderboard_view.dart'; import 'package:window_manager/window_manager.dart'; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; import 'package:sqflite_common_ffi_web/sqflite_ffi_web.dart'; @@ -18,11 +24,40 @@ import 'package:go_router/go_router.dart'; late final PackageInfo packageInfo; late SharedPreferences prefs; -ColorScheme sheme = const ColorScheme.dark(primary: Colors.cyanAccent, secondary: Colors.white); +late TetrioService teto; +ThemeData theme = ThemeData(fontFamily: 'Eurostile Round', colorScheme: const ColorScheme.dark(primary: Colors.cyanAccent, secondary: Colors.white), scaffoldBackgroundColor: Colors.black); -void setAccentColor(Color color){ // does this thing work??? yes??? no??? - sheme = ColorScheme.dark(primary: color, secondary: Colors.white); -} +// Future computeIsolate(Future Function() function) async { +// final receivePort = ReceivePort(); +// var rootToken = RootIsolateToken.instance!; +// await Isolate.spawn<_IsolateData>( +// _isolateEntry, +// _IsolateData( +// token: rootToken, +// function: function, +// answerPort: receivePort.sendPort, +// ), +// ); +// return await receivePort.first; +// } + +// void _isolateEntry(_IsolateData isolateData) async { +// BackgroundIsolateBinaryMessenger.ensureInitialized(isolateData.token); +// final answer = await isolateData.function(); +// isolateData.answerPort.send(answer); +// } + +// class _IsolateData { +// final RootIsolateToken token; +// final Function function; +// final SendPort answerPort; + +// _IsolateData({ +// required this.token, +// required this.function, +// required this.answerPort, +// }); +// } final router = GoRouter( initialLocation: "/", @@ -34,6 +69,26 @@ final router = GoRouter( GoRoute( path: 'settings', builder: (_, __) => const SettingsView(), + routes: [ + GoRoute( + path: 'customization', + builder: (_, __) => const CustomizationView(), + ), + ] + ), + GoRoute( + path: "leaderboard", + builder: (_, __) => const TLLeaderboardView(), + routes: [ + GoRoute( + path: "LBvalues", + builder: (_, __) => const RankAveragesView(), + ), + ] + ), + GoRoute( + path: "LBvalues", + builder: (_, __) => const RankAveragesView(), ), GoRoute( path: 'states', @@ -44,9 +99,9 @@ final router = GoRouter( builder: (_, __) => const CalcView(), ), GoRoute( - path: 'customization', - builder: (_, __) => const CustomizationView(), - ), + path: 'sprintAndBlitzAverages', + builder: (_, __) => const SprintAndBlitzView(), + ) ] ), GoRoute( // that one intended for Android users, that can open https://ch.tetr.io/u/ links @@ -77,6 +132,7 @@ void main() async { packageInfo = await PackageInfo.fromPlatform(); prefs = await SharedPreferences.getInstance(); + teto = TetrioService(); // Choosing the locale String? locale = prefs.getString("locale"); @@ -85,15 +141,40 @@ void main() async { }else{ LocaleSettings.setLocaleRaw(locale); } + + // I dont want to store old cache + Timer.periodic(const Duration(minutes: 5), (Timer timer) { + teto.cacheRoutine(); + developer.log("Cache routine complete, next one in ${DateTime.now().add(const Duration(minutes: 5))}", name: "main"); + // if (prefs.getBool("updateInBG") == true) teto.fetchTracked(); // TODO: Somehow avoid doing that in main isolate + }); runApp(TranslationProvider( child: const MyApp(), )); } -class MyApp extends StatelessWidget { +class MyApp extends StatefulWidget { const MyApp({super.key}); + @override + State createState() => MyAppState(); +} + +class MyAppState extends State { + + @override + void initState() { + setAccentColor(prefs.getInt("accentColor") != null ? Color(prefs.getInt("accentColor")!) : Colors.cyanAccent); + super.initState(); + } + + void setAccentColor(Color color){ // does this thing work??? yes??? no??? + setState(() { + theme = theme.copyWith(colorScheme: theme.colorScheme.copyWith(primary: color)); + }); + } + @override Widget build(BuildContext context) { return MaterialApp.router( @@ -105,11 +186,7 @@ class MyApp extends StatelessWidget { locale: TranslationProvider.of(context).flutterLocale, supportedLocales: AppLocaleUtils.supportedLocales, localizationsDelegates: GlobalMaterialLocalizations.delegates, - theme: ThemeData( - fontFamily: 'Eurostile Round', - colorScheme: sheme, - scaffoldBackgroundColor: Colors.black - ) + theme: theme ); } } diff --git a/lib/services/tetrio_crud.dart b/lib/services/tetrio_crud.dart index 8c118ae..3911cd5 100644 --- a/lib/services/tetrio_crud.dart +++ b/lib/services/tetrio_crud.dart @@ -1,8 +1,12 @@ +// ignore_for_file: type_literal_in_constant_pattern + import 'dart:async'; import 'dart:convert'; import 'dart:developer' as developer; import 'dart:io'; +import 'package:flutter/material.dart'; import 'package:path_provider/path_provider.dart'; +import 'package:tetra_stats/data_objects/tetra_stats.dart'; import 'package:tetra_stats/data_objects/tetrio_multiplayer_replay.dart'; import 'package:tetra_stats/main.dart' show packageInfo; import 'package:flutter/foundation.dart'; @@ -65,22 +69,79 @@ const String createTetrioTLReplayStats = ''' ) '''; +class CacheController { + late Map _cache; + late Map _nicknames; + + CacheController.init(){ + _cache = {}; + _nicknames = {}; + } + + String _getObjectId(dynamic object){ + switch (object.runtimeType){ + case TetrioPlayer: + object as TetrioPlayer; + _nicknames[object.username] = object.userId; + return object.userId; + case TetrioPlayersLeaderboard: + return object.runtimeType.toString()+object.type; + case Cutoffs: + return object.runtimeType.toString(); + case TetrioPlayerFromLeaderboard: // i may be a little stupid + return "${object.runtimeType}topone"; + case TetraLeagueAlphaStream: + return object.runtimeType.toString()+object.userId; + case SingleplayerStream: + return object.type+object.userId; + default: + return object.runtimeType.toString()+object.id; + } + } + + void store(dynamic object, int? cachedUntil) async { + String key = _getObjectId(object) + cachedUntil!.toString(); + _cache[key] = object; + } + + dynamic get(String id, Type datatype){ + if (_cache.isEmpty) return null; + MapEntry? objectEntry; + try{ + switch (datatype){ + case TetrioPlayer: + objectEntry = id.length <= 16 ? _cache.entries.firstWhere((element) => element.key.startsWith(_nicknames[id]??"huh?")) : _cache.entries.firstWhere((element) => element.key.startsWith(id)); + if (id.length <= 16) id = _nicknames[id]??"huh?"; + break; + default: + objectEntry = _cache.entries.firstWhere((element) => element.key.startsWith(datatype.toString()+id)); + id = datatype.toString()+id; + break; + } + } on StateError{ + return null; + } + if (int.parse(objectEntry.key.substring(id.length)) <= DateTime.now().millisecondsSinceEpoch){ + _cache.remove(objectEntry.key); + return null; + }else{ + return objectEntry.value; + } + } + + void removeOld() async { + _cache.removeWhere((key, value) => int.parse(key.substring(_getObjectId(value).length)) <= DateTime.now().millisecondsSinceEpoch); + } + + void reset(){ + _cache.clear(); + } +} + class TetrioService extends DB { 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} - // TODO: Make a proper caching system - final Map _playersCache = {}; - final Map> _recordsCache = {}; - final Map _replaysCache = {}; // the only one is different: {"replayID": [replayString, replayBytes]} - final Map _leaderboardsCache = {}; - final Map _lbPositions = {}; - final Map> _newsCache = {}; - final Map> _topTRcache = {}; - final Map>> _cutoffsCache = {}; - final Map _topOneFromLB = {}; - final Map _tlStreamsCache = {}; + final _cache = CacheController.init(); // I'm trying to send as less requests, as possible, so i'm caching the results of those requests. + final Map _lbPositions = {}; // separate one because attached to the leaderboard /// Thing, that sends every request to the API endpoints final client = kDebugMode ? UserAgentClient("Kagari-chan loves osk (Tetra Stats dev build)", http.Client()) : UserAgentClient("Tetra Stats v${packageInfo.version} (dm @dan63047 if someone abuse that software)", http.Client()); /// We should have only one instanse of this service @@ -154,17 +215,27 @@ class TetrioService extends DB { return _lbPositions[userID]; } + void cacheRoutine(){ + _cache.removeOld(); + } + /// Downloads replay from inoue (szy API). Requiers [replayID]. If request have /// different from 200 statusCode, it will throw an excepction. Returns list, that contains same replay /// as string and as binary. - Future> szyGetReplay(String replayID) async { - try{ // read from cache - var cached = _replaysCache.entries.firstWhere((element) => element.key == replayID); - return cached.value; - }catch (e){ - // actually going to obtain + Future szyGetReplay(String replayID) async { + // Trying to get it from cache first + RawReplay? cached = _cache.get(replayID, RawReplay); + if (cached != null) return cached; + + // If failed, trying to obtain replay from download directory + if (!kIsWeb){ // can't obtain download directory on web + var downloadPath = await getDownloadsDirectory(); + downloadPath ??= Platform.isAndroid ? Directory("/storage/emulated/0/Download") : await getApplicationDocumentsDirectory(); + var replayFile = File("${downloadPath.path}/$replayID.ttrm"); + if (replayFile.existsSync()) return RawReplay(replayID, replayFile.readAsBytesSync(), replayFile.readAsStringSync()); } + // If failed, actually trying to retrieve Uri url; if (kIsWeb) { // Web version sends every request through my php script at the same domain, where Tetra Stats located because of CORS url = Uri.https('ts.dan63.by', 'oskware_bridge.php', {"endpoint": "tetrioReplay", "replayid": replayID}); @@ -172,22 +243,16 @@ class TetrioService extends DB { url = Uri.https('inoue.szy.lol', '/api/replay/$replayID'); } - // Trying to obtain replay from download directory first - if (!kIsWeb){ // can't obtain download directory on web - var downloadPath = await getDownloadsDirectory(); - downloadPath ??= Platform.isAndroid ? Directory("/storage/emulated/0/Download") : await getApplicationDocumentsDirectory(); - var replayFile = File("${downloadPath.path}/$replayID.ttrm"); - if (replayFile.existsSync()) return [replayFile.readAsStringSync(), replayFile.readAsBytesSync()]; - } - try{ final response = await client.get(url); switch (response.statusCode) { case 200: - developer.log("szyDownload: Replay downloaded", name: "services/tetrio_crud", error: response.statusCode); - _replaysCache[replayID] = [response.body, response.bodyBytes]; // Puts results into the cache - return [response.body, response.bodyBytes]; + developer.log("szyDownload: Replay $replayID downloaded", name: "services/tetrio_crud"); + RawReplay replay = RawReplay(replayID, response.bodyBytes, response.body); + DateTime now = DateTime.now(); + _cache.store(replay, now.millisecondsSinceEpoch + 3600000); + return replay; // if not 200 - throw a unique for each code exception case 404: throw SzyNotFound(); @@ -203,7 +268,7 @@ class TetrioService extends DB { case 504: throw SzyInternalProblem(); default: - developer.log("szyDownload: Failed to download a replay", name: "services/tetrio_crud", error: response.statusCode); + developer.log("szyDownload: Failed to download a replay $replayID", name: "services/tetrio_crud", error: response.statusCode); throw ConnectionIssue(response.statusCode, response.reasonPhrase??"No reason"); } } on http.ClientException catch (e, s) { // If local http client fails @@ -219,8 +284,8 @@ class TetrioService extends DB { downloadPath ??= Platform.isAndroid ? Directory("/storage/emulated/0/Download") : await getApplicationDocumentsDirectory(); var replayFile = File("${downloadPath.path}/$replayID.ttrm"); if (replayFile.existsSync()) throw TetrioReplayAlreadyExist(); - var replay = await szyGetReplay(replayID); - await replayFile.writeAsBytes(replay[1]); + RawReplay replay = await szyGetReplay(replayID); + await replayFile.writeAsBytes(replay.asBytes); return replayFile.path; } @@ -235,28 +300,66 @@ class TetrioService extends DB { if (!isAvailable) throw ReplayNotAvalable(); // if replay too old // otherwise, actually going to download a replay and analyze it - String replay = (await szyGetReplay(replayID))[0]; + String replay = (await szyGetReplay(replayID)).asString; Map toAnalyze = jsonDecode(replay); ReplayData data = ReplayData.fromJson(toAnalyze); saveReplayStats(data); // saving to DB for later return data; } + /// Retrieves avaliable Tetra League matches from Tetra Channel api. Returns stream object (fake stream). + /// Throws an exception if fails to retrieve. + Future fetchSingleplayerStream(String userID, String stream) async { + SingleplayerStream? cached = _cache.get(userID, SingleplayerStream); + if (cached != null) return cached; + + Uri url; + if (kIsWeb) { + url = Uri.https('ts.dan63.by', 'oskware_bridge.php', {"endpoint": "singleplayerStream", "user": userID.toLowerCase().trim(), "stream": stream}); + } else { + url = Uri.https('ch.tetr.io', 'api/streams/${stream}_${userID.toLowerCase().trim()}'); + } + try { + final response = await client.get(url); + + switch (response.statusCode) { + case 200: + if (jsonDecode(response.body)['success']) { + SingleplayerStream records = SingleplayerStream.fromJson(jsonDecode(response.body)['data']['records'], userID, stream); + _cache.store(records, jsonDecode(response.body)['cache']['cached_until']); + developer.log("fetchSingleplayerStream: $stream $userID stream retrieved and cached", name: "services/tetrio_crud"); + return records; + } else { + developer.log("fetchSingleplayerStream: User dosen't exist", name: "services/tetrio_crud", error: response.body); + throw TetrioPlayerNotExist(); + } + case 403: + throw TetrioForbidden(); + case 429: + throw TetrioTooManyRequests(); + case 418: + throw TetrioOskwareBridgeProblem(); + case 500: + case 502: + case 503: + case 504: + throw TetrioInternalProblem(); + default: + developer.log("fetchSingleplayerStream: Failed to fetch stream $stream $userID", name: "services/tetrio_crud", error: response.statusCode); + throw ConnectionIssue(response.statusCode, response.reasonPhrase??"No reason"); + } + } on http.ClientException catch (e, s) { + developer.log("$e, $s"); + throw http.ClientException(e.message, e.uri); + } + } + /// Gets and returns Top TR for a player with given [id]. May return null if player top tr is unknown /// or api is unavaliable (404). May throw an exception, if something else happens. - Future fetchTopTR(String id) async { - try{ // read from cache - var cached = _topTRcache.entries.firstWhere((element) => element.value.keys.first == id); - if (DateTime.fromMillisecondsSinceEpoch(int.parse(cached.key.toString()), isUtc: true).isAfter(DateTime.now())){ // if not expired - developer.log("fetchTopTR: Top TR retrieved from cache, that expires ${DateTime.fromMillisecondsSinceEpoch(int.parse(cached.key.toString()), isUtc: true)}", name: "services/tetrio_crud"); - return cached.value.values.first; - }else{ // if cache expired - _topTRcache.remove(cached.key); - developer.log("fetchTopTR: Top TR expired (${DateTime.fromMillisecondsSinceEpoch(int.parse(cached.key.toString()), isUtc: true)})", name: "services/tetrio_crud"); - } - }catch(e){ // actually going to obtain - developer.log("fetchTopTR: Trying to retrieve Top TR", name: "services/tetrio_crud"); - } + Future fetchTopTR(String id) async { + // Trying to get it from cache first + TopTr? cached = _cache.get(id, TopTr); + if (cached != null) return cached; Uri url; if (kIsWeb) { // Web version sends every request through my php script at the same domain, where Tetra Stats located because of CORS @@ -269,12 +372,15 @@ class TetrioService extends DB { switch (response.statusCode) { case 200: // ok - return the value - _topTRcache[(DateTime.now().millisecondsSinceEpoch + 300000).toString()] = {id: double.tryParse(response.body)}; - return double.tryParse(response.body); + TopTr result = TopTr(id, double.tryParse(response.body)); + _cache.store(result, DateTime.now().millisecondsSinceEpoch + 300000); + return result; case 404: // not found - return null + TopTr result = TopTr(id, null); developer.log("fetchTopTR: Probably, player doesn't have top TR", name: "services/tetrio_crud", error: response.statusCode); - _topTRcache[(DateTime.now().millisecondsSinceEpoch + 300000).toString()] = {id: null}; - return null; + _cache.store(result, DateTime.now().millisecondsSinceEpoch + 300000); + //_topTRcache[(DateTime.now().millisecondsSinceEpoch + 300000).toString()] = {id: null}; + return result; // if not 200 or 404 - throw a unique for each code exception case 403: throw P1nkl0bst3rForbidden(); @@ -300,19 +406,9 @@ class TetrioService extends DB { // Sidenote: as you can see, fetch functions looks and works pretty much same way, as described above, // so i'm going to document only unique differences between them - Future>> fetchCutoffs() async { - try{ - var cached = _cutoffsCache.entries.first; - if (DateTime.fromMillisecondsSinceEpoch(int.parse(cached.key.toString()), isUtc: true).isAfter(DateTime.now())){ // if not expired - developer.log("fetchCutoffs: Cutoffs retrieved from cache, that expires ${DateTime.fromMillisecondsSinceEpoch(int.parse(cached.key.toString()), isUtc: true)}", name: "services/tetrio_crud"); - return cached.value; - }else{ // if cache expired - _topTRcache.remove(cached.key); - developer.log("fetchCutoffs: Cutoffs expired (${DateTime.fromMillisecondsSinceEpoch(int.parse(cached.key.toString()), isUtc: true)})", name: "services/tetrio_crud"); - } - }catch(e){ // actually going to obtain - developer.log("fetchCutoffs: Trying to retrieve Cutoffs", name: "services/tetrio_crud"); - } + Future fetchCutoffs() async { + Cutoffs? cached = _cache.get("", Cutoffs); + if (cached != null) return cached; Uri url; if (kIsWeb) { @@ -328,17 +424,16 @@ class TetrioService extends DB { case 200: Map rawData = jsonDecode(response.body); Map data = rawData["cutoffs"] as Map; - Map trCutoffs = {}; - Map glickoCutoffs = {}; + Cutoffs result = Cutoffs({}, {}); for (String rank in data.keys){ - trCutoffs[rank] = data[rank]["rating"]; - glickoCutoffs[rank] = data[rank]["glicko"]; + result.tr[rank] = data[rank]["rating"]; + result.glicko[rank] = data[rank]["glicko"]; } - _cutoffsCache[(rawData["ts"] + 300000).toString()] = [trCutoffs, glickoCutoffs]; - return [trCutoffs, glickoCutoffs]; + _cache.store(result, rawData["ts"] + 300000); + return result; case 404: developer.log("fetchCutoffs: Cutoffs are gone", name: "services/tetrio_crud", error: response.statusCode); - return []; + return null; // if not 200 or 404 - throw a unique for each code exception case 403: throw P1nkl0bst3rForbidden(); @@ -362,18 +457,8 @@ class TetrioService extends DB { } Future fetchTopOneFromTheLeaderboard() async { - try{ - var cached = _topOneFromLB.entries.first; - if (DateTime.fromMillisecondsSinceEpoch(int.parse(cached.key.toString()), isUtc: true).isAfter(DateTime.now())){ // if not expired - developer.log("fetchTopOneFromTheLeaderboard: Leader retrieved from cache, that expires ${DateTime.fromMillisecondsSinceEpoch(int.parse(cached.key.toString()), isUtc: true)}", name: "services/tetrio_crud"); - return cached.value; - }else{ // if cache expired - _topTRcache.remove(cached.key); - developer.log("fetchTopOneFromTheLeaderboard: Leader expired (${DateTime.fromMillisecondsSinceEpoch(int.parse(cached.key.toString()), isUtc: true)})", name: "services/tetrio_crud"); - } - }catch(e){ // actually going to obtain - developer.log("fetchTopOneFromTheLeaderboard: Trying to retrieve leader", name: "services/tetrio_crud"); - } + TetrioPlayerFromLeaderboard? cached = _cache.get("topone", TetrioPlayerFromLeaderboard); + if (cached != null) return cached; Uri url; if (kIsWeb) { @@ -388,7 +473,9 @@ class TetrioService extends DB { switch (response.statusCode) { case 200: var rawJson = jsonDecode(response.body); - return TetrioPlayerFromLeaderboard.fromJson(rawJson["data"]["users"][0], DateTime.fromMillisecondsSinceEpoch(rawJson["cache"]["cached_at"])); + TetrioPlayerFromLeaderboard result = TetrioPlayerFromLeaderboard.fromJson(rawJson["data"]["users"][0], DateTime.fromMillisecondsSinceEpoch(rawJson["cache"]["cached_at"])); + _cache.store(result, rawJson["cache"]["cached_until"]); + return result; case 404: throw TetrioPlayerNotExist(); // if not 200 or 404 - throw a unique for each code exception @@ -606,18 +693,9 @@ class TetrioService extends DB { /// Retrieves full Tetra League leaderboard from Tetra Channel api. Returns a leaderboard object. Throws an exception if fails to retrieve. Future fetchTLLeaderboard() async { - try{ - var cached = _leaderboardsCache.entries.firstWhere((element) => element.value.type == "league"); - if (DateTime.fromMillisecondsSinceEpoch(int.parse(cached.key.toString()), isUtc: true).isAfter(DateTime.now())){ - developer.log("fetchTLLeaderboard: Leaderboard retrieved from cache, that expires ${DateTime.fromMillisecondsSinceEpoch(int.parse(cached.key.toString()), isUtc: true)}", name: "services/tetrio_crud"); - return cached.value; - }else{ - _leaderboardsCache.remove(cached.key); - developer.log("fetchTLLeaderboard: Leaderboard expired (${DateTime.fromMillisecondsSinceEpoch(int.parse(cached.key.toString()), isUtc: true)})", name: "services/tetrio_crud"); - } - }catch(e){ - developer.log("fetchTLLeaderboard: Trying to retrieve leaderboard", name: "services/tetrio_crud"); - } + TetrioPlayersLeaderboard? cached = _cache.get("league", TetrioPlayersLeaderboard); + if (cached != null) return cached; + Uri url; if (kIsWeb) { url = Uri.https('ts.dan63.by', 'oskware_bridge.php', {"endpoint": "TLLeaderboard"}); @@ -634,7 +712,8 @@ class TetrioService extends DB { if (rawJson['success']) { // if api confirmed that everything ok TetrioPlayersLeaderboard leaderboard = TetrioPlayersLeaderboard.fromJson(rawJson['data']['users'], "league", DateTime.fromMillisecondsSinceEpoch(rawJson['cache']['cached_at'])); developer.log("fetchTLLeaderboard: Leaderboard retrieved and cached", name: "services/tetrio_crud"); - _leaderboardsCache[rawJson['cache']['cached_until'].toString()] = leaderboard; + //_leaderboardsCache[rawJson['cache']['cached_until'].toString()] = leaderboard; + _cache.store(leaderboard, rawJson['cache']['cached_until']); return leaderboard; } else { // idk how to hit that one developer.log("fetchTLLeaderboard: Bruh", name: "services/tetrio_crud", error: rawJson); @@ -661,26 +740,29 @@ class TetrioService extends DB { } } + // i want to know progress, so i trying to figure out this thing: + // Stream fetchTLLeaderboardAsStream() async { + // TetrioPlayersLeaderboard? cached = _cache.get("league", TetrioPlayersLeaderboard); + // if (cached != null) return cached; + + // Uri url; + // if (kIsWeb) { + // url = Uri.https('ts.dan63.by', 'oskware_bridge.php', {"endpoint": "TLLeaderboard"}); + // } else { + // url = Uri.https('ch.tetr.io', 'api/users/lists/league/all'); + // } + + // Stream stream = http.StreamedRequest("GET", url); + // } + TetrioPlayersLeaderboard? getCachedLeaderboard(){ - return _leaderboardsCache.entries.firstOrNull?.value; - // That function will break if i decide to recive other leaderboards - // TODO: Think about better solution + return _cache.get("league", TetrioPlayersLeaderboard); } /// Retrieves and returns 100 latest news entries from Tetra Channel api for given [userID]. Throws an exception if fails to retrieve. - Future> fetchNews(String userID) async{ - try{ - var cached = _newsCache.entries.firstWhere((element) => element.value[0].stream == "user_$userID"); - if (DateTime.fromMillisecondsSinceEpoch(int.parse(cached.key.toString()), isUtc: true).isAfter(DateTime.now())){ - developer.log("fetchNews: News for $userID retrieved from cache, that expires ${DateTime.fromMillisecondsSinceEpoch(int.parse(cached.key.toString()), isUtc: true)}", name: "services/tetrio_crud"); - return cached.value; - }else{ - _newsCache.remove(cached.key); - developer.log("fetchNews: Cached news for $userID expired (${DateTime.fromMillisecondsSinceEpoch(int.parse(cached.key.toString()), isUtc: true)})", name: "services/tetrio_crud"); - } - }catch(e){ - developer.log("fetchNews: Trying to retrieve news for $userID", name: "services/tetrio_crud"); - } + Future fetchNews(String userID) async{ + News? cached = _cache.get(userID, News); + if (cached != null) return cached; Uri url; if (kIsWeb) { @@ -695,8 +777,8 @@ class TetrioService extends DB { case 200: var payload = jsonDecode(response.body); if (payload['success']) { // if api confirmed that everything ok - List news = [for (var entry in payload['data']['news']) News.fromJson(entry)]; - _newsCache[payload['cache']['cached_until'].toString()] = news; + News news = News.fromJson(payload['data'], userID); + _cache.store(news, payload['cache']['cached_until']); developer.log("fetchNews: $userID news retrieved and cached", name: "services/tetrio_crud"); return news; } else { @@ -727,18 +809,8 @@ class TetrioService extends DB { /// Retrieves avaliable Tetra League matches from Tetra Channel api. Returns stream object (fake stream). /// Throws an exception if fails to retrieve. Future fetchTLStream(String userID) async { - try{ - var cached = _tlStreamsCache.entries.firstWhere((element) => element.value.userId == userID); - if (DateTime.fromMillisecondsSinceEpoch(int.parse(cached.key.toString()), isUtc: true).isAfter(DateTime.now())){ - developer.log("fetchTLStream: Stream $userID retrieved from cache, that expires ${DateTime.fromMillisecondsSinceEpoch(int.parse(cached.key.toString()), isUtc: true)}", name: "services/tetrio_crud"); - return cached.value; - }else{ - _tlStreamsCache.remove(cached.key); - developer.log("fetchTLStream: Cached stream $userID expired (${DateTime.fromMillisecondsSinceEpoch(int.parse(cached.key.toString()), isUtc: true)})", name: "services/tetrio_crud"); - } - }catch(e){ - developer.log("fetchTLStream: Trying to retrieve stream $userID", name: "services/tetrio_crud"); - } + TetraLeagueAlphaStream? cached = _cache.get(userID, TetraLeagueAlphaStream); + if (cached != null) return cached; Uri url; if (kIsWeb) { @@ -753,7 +825,7 @@ class TetrioService extends DB { case 200: if (jsonDecode(response.body)['success']) { TetraLeagueAlphaStream stream = TetraLeagueAlphaStream.fromJson(jsonDecode(response.body)['data']['records'], userID); - _tlStreamsCache[jsonDecode(response.body)['cache']['cached_until'].toString()] = stream; + _cache.store(stream, jsonDecode(response.body)['cache']['cached_until']); developer.log("fetchTLStream: $userID stream retrieved and cached", name: "services/tetrio_crud"); return stream; } else { @@ -864,21 +936,11 @@ class TetrioService extends DB { await db.delete(tetrioTLReplayStatsTable, where: '$idCol = ?', whereArgs: [rID]); } - /// 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{ - var cached = _recordsCache.entries.firstWhere((element) => element.value['user'] == userID); - if (DateTime.fromMillisecondsSinceEpoch(int.parse(cached.key.toString()), isUtc: true).isAfter(DateTime.now())){ - developer.log("fetchRecords: $userID records retrieved from cache, that expires ${DateTime.fromMillisecondsSinceEpoch(int.parse(cached.key.toString()), isUtc: true)}", name: "services/tetrio_crud"); - return cached.value; - }else{ - _recordsCache.remove(cached.key); - developer.log("fetchRecords: $userID records expired (${DateTime.fromMillisecondsSinceEpoch(int.parse(cached.key.toString()), isUtc: true)})", name: "services/tetrio_crud"); - } - }catch(e){ - developer.log("fetchRecords: Trying to retrieve $userID records", name: "services/tetrio_crud"); - } + /// Retrieves Blitz, 40 Lines and Zen records for a given [userID] from Tetra Channel api. Returns `UserRecords`. + /// Throws an exception if fails to retrieve. + Future fetchRecords(String userID) async { + UserRecords? cached = _cache.get(userID, UserRecords); + if (cached != null) return cached; Uri url; if (kIsWeb) { @@ -892,7 +954,7 @@ class TetrioService extends DB { switch (response.statusCode) { case 200: if (jsonDecode(response.body)['success']) { - Map jsonRecords = jsonDecode(response.body); + Map jsonRecords = jsonDecode(response.body); var sprint = jsonRecords['data']['records']['40l']['record'] != null ? RecordSingle.fromJson(jsonRecords['data']['records']['40l']['record'], jsonRecords['data']['records']['40l']['rank']) : null; @@ -900,10 +962,10 @@ class TetrioService extends DB { ? RecordSingle.fromJson(jsonRecords['data']['records']['blitz']['record'], jsonRecords['data']['records']['blitz']['rank']) : null; var zen = TetrioZen.fromJson(jsonRecords['data']['zen']); - Map map = {"user": userID.toLowerCase().trim(), "sprint": sprint, "blitz": blitz, "zen": zen}; - _recordsCache[jsonDecode(response.body)['cache']['cached_until'].toString()] = map; + UserRecords result = UserRecords(userID, sprint, blitz, zen); + _cache.store(result, jsonDecode(response.body)['cache']['cached_until']); developer.log("fetchRecords: $userID records retrieved and cached", name: "services/tetrio_crud"); - return map; + return result; } else { developer.log("fetchRecords User dosen't exist", name: "services/tetrio_crud", error: response.body); throw TetrioPlayerNotExist(); @@ -997,8 +1059,7 @@ class TetrioService extends DB { } // we not going to add state, that is same, as the previous - bool test = states.last.isSameState(tetrioPlayer); - if (test == false) states.add(tetrioPlayer); + if (!states.last.isSameState(tetrioPlayer)) states.add(tetrioPlayer); // Making map of the states final Map statesJson = {}; @@ -1058,18 +1119,8 @@ class TetrioService extends DB { /// Retrieves general stats of [user] (nickname or id) from Tetra Channel api. Returns [TetrioPlayer] object of this user. /// If [isItDiscordID] is true, function expects [user] to be a discord user id. Throws an exception if fails to retrieve. Future fetchPlayer(String user, {bool isItDiscordID = false}) async { - try{ - var cached = _playersCache.entries.firstWhere((element) => element.value.userId == user || element.value.username == user); - if (DateTime.fromMillisecondsSinceEpoch(int.parse(cached.key.toString()), isUtc: true).isAfter(DateTime.now())){ - developer.log("fetchPlayer: User $user retrieved from cache, that expires ${DateTime.fromMillisecondsSinceEpoch(int.parse(cached.key.toString()), isUtc: true)}", name: "services/tetrio_crud"); - return cached.value; - }else{ - _playersCache.remove(cached.key); - developer.log("fetchPlayer: Cached user $user expired (${DateTime.fromMillisecondsSinceEpoch(int.parse(cached.key.toString()), isUtc: true)})", name: "services/tetrio_crud"); - } - }catch(e){ - developer.log("fetchPlayer: Trying to retrieve $user", name: "services/tetrio_crud"); - } + TetrioPlayer? cached = _cache.get(user, TetrioPlayer); + if (cached != null) return cached; if (isItDiscordID){ // trying to find player with given discord id @@ -1130,8 +1181,8 @@ class TetrioService extends DB { var json = jsonDecode(response.body); if (json['success']) { // parse and count stats - TetrioPlayer player = TetrioPlayer.fromJson(json['data']['user'], DateTime.fromMillisecondsSinceEpoch(json['cache']['cached_at'], isUtc: true), json['data']['user']['_id'], json['data']['user']['username']); - _playersCache[jsonDecode(response.body)['cache']['cached_until'].toString()] = player; + TetrioPlayer player = TetrioPlayer.fromJson(json['data']['user'], DateTime.fromMillisecondsSinceEpoch(json['cache']['cached_at'], isUtc: true), json['data']['user']['_id'], json['data']['user']['username'], DateTime.fromMillisecondsSinceEpoch(json['cache']['cached_until'], isUtc: true)); + _cache.store(player, json['cache']['cached_until']); developer.log("fetchPlayer: $user retrieved and cached", name: "services/tetrio_crud"); return player; } else { @@ -1173,4 +1224,15 @@ class TetrioService extends DB { } return data; } + + Future fetchTracked() async { + for (String userID in (await getAllPlayerToTrack())) { + TetrioPlayer player = await fetchPlayer(userID); + storeState(player); + sleep(Durations.extralong4); + TetraLeagueAlphaStream matches = await fetchTLStream(userID); + saveTLMatchesFromStream(matches); + sleep(Durations.extralong4); + } + } } diff --git a/lib/utils/numers_formats.dart b/lib/utils/numers_formats.dart index bd6c119..097b705 100644 --- a/lib/utils/numers_formats.dart +++ b/lib/utils/numers_formats.dart @@ -8,5 +8,13 @@ final NumberFormat f4 = NumberFormat.decimalPatternDigits(locale: LocaleSettings final NumberFormat f3 = NumberFormat.decimalPatternDigits(locale: LocaleSettings.currentLocale.languageCode, decimalDigits: 3); final NumberFormat f2 = NumberFormat.decimalPatternDigits(locale: LocaleSettings.currentLocale.languageCode, decimalDigits: 2); final NumberFormat f2l = NumberFormat.decimalPatternDigits(locale: LocaleSettings.currentLocale.languageCode, decimalDigits: 2)..minimumFractionDigits = 0; +final NumberFormat f1 = NumberFormat.decimalPatternDigits(locale: LocaleSettings.currentLocale.languageCode, decimalDigits: 1); final NumberFormat f0 = NumberFormat.decimalPattern(LocaleSettings.currentLocale.languageCode); -final NumberFormat percentage = NumberFormat.percentPattern(LocaleSettings.currentLocale.languageCode)..maximumFractionDigits = 2; \ No newline at end of file +final NumberFormat percentage = NumberFormat.percentPattern(LocaleSettings.currentLocale.languageCode)..maximumFractionDigits = 2; + +/// Readable [a] - [b], without sign +String readableIntDifference(int a, int b){ + int result = a - b; + + return NumberFormat("#,###;#,###", LocaleSettings.currentLocale.languageCode).format(result); +} \ No newline at end of file diff --git a/lib/utils/relative_timestamps.dart b/lib/utils/relative_timestamps.dart new file mode 100644 index 0000000..0e9260f --- /dev/null +++ b/lib/utils/relative_timestamps.dart @@ -0,0 +1,76 @@ +import 'package:intl/intl.dart'; +import 'package:tetra_stats/gen/strings.g.dart'; +import 'package:tetra_stats/utils/numers_formats.dart'; + +final NumberFormat secs = NumberFormat("00.###", LocaleSettings.currentLocale.languageCode); +final NumberFormat _timeInSec = NumberFormat("#,###.###s.", LocaleSettings.currentLocale.languageCode); + +/// Returns string, that represents time difference between [dateTime] and now +String relativeDateTime(DateTime dateTime){ + Duration difference = dateTime.difference(DateTime.now()); + bool inPast = difference.isNegative; + Duration absDifference = difference.abs(); + double timeInterval; + + // years + timeInterval = absDifference.inSeconds / 31536000; + if (timeInterval >= 100.0) { + return inPast ? "${timeInterval.truncate()} years ago" : "in ${timeInterval.truncate()} years"; + } else if (timeInterval >= 10.0) { + return inPast ? "${f1.format(timeInterval)} years ago" : "in ${f1.format(timeInterval)} years"; + } else if (timeInterval >= 1.0) { + return inPast ? "${f2.format(timeInterval)} years ago" : "in ${f2.format(timeInterval)} years"; + } + + // months + timeInterval = absDifference.inSeconds / 2592000; + if (timeInterval >= 10.0) { + return inPast ? "${timeInterval.truncate()} months ago" : "in ${timeInterval.truncate()} months"; + } else if (timeInterval >= 1.0) { + return inPast ? "${f1.format(timeInterval)} months ago" : "in ${f1.format(timeInterval)} months"; + } + + // days + timeInterval = absDifference.inSeconds / 86400; + if (timeInterval >= 10.0) { + return inPast ? "${timeInterval.truncate()} days ago" : "in ${timeInterval.truncate()} days"; + } else if (timeInterval >= 1.0) { + return inPast ? "${f1.format(timeInterval)} days ago" : "in ${f1.format(timeInterval)} days"; + } + + // hours + timeInterval = absDifference.inSeconds / 3600; + if (timeInterval >= 10.0) { + return inPast ? "${timeInterval.truncate()} hours ago" : "in ${timeInterval.truncate()} hours"; + } else if (timeInterval >= 1.0) { + return inPast ? "${f1.format(timeInterval)} hours ago" : "in ${f1.format(timeInterval)} hours"; + } + + // minutes + timeInterval = absDifference.inSeconds / 60; + if (timeInterval >= 10.0) { + return inPast ? "${timeInterval.truncate()} minutes ago" : "in ${timeInterval.truncate()} minutes"; + } else if (timeInterval >= 1.0) { + return inPast ? "${f1.format(timeInterval)} minutes ago" : "in ${f1.format(timeInterval)} minutes"; + } + + // seconds + timeInterval = absDifference.inMilliseconds / 1000; + if (timeInterval >= 10.0) { + return inPast ? "${timeInterval.truncate()} seconds ago" : "in ${timeInterval.truncate()} seconds"; + } else { + return inPast ? "${f1.format(timeInterval)} seconds ago" : "in ${f1.format(timeInterval)} seconds"; + } +} + +/// Takes number of [microseconds] and returns readable 40 lines time +String get40lTime(int microseconds){ + return microseconds > 60000000 ? "${(microseconds/1000000/60).floor()}:${(secs.format(microseconds /1000000 % 60))}" : _timeInSec.format(microseconds / 1000000); +} + +/// Readable [a] - [b], without sign +String readableTimeDifference(Duration a, Duration b){ + Duration result = a - b; + + return NumberFormat("0.000s;0.000s", LocaleSettings.currentLocale.languageCode).format(result.inMilliseconds/1000); +} \ No newline at end of file diff --git a/lib/views/compare_view.dart b/lib/views/compare_view.dart index 3129ea8..2916a0e 100644 --- a/lib/views/compare_view.dart +++ b/lib/views/compare_view.dart @@ -1,3 +1,5 @@ +// ignore_for_file: use_build_context_synchronously + import 'dart:io'; import 'dart:math'; import 'package:flutter/foundation.dart'; @@ -5,7 +7,7 @@ import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:tetra_stats/data_objects/tetrio.dart'; import 'package:tetra_stats/gen/strings.g.dart'; -import 'package:tetra_stats/services/tetrio_crud.dart'; +import 'package:tetra_stats/main.dart' show teto; import 'package:tetra_stats/widgets/vs_graphs.dart'; import 'package:window_manager/window_manager.dart'; @@ -18,7 +20,6 @@ Mode greenSideMode = Mode.player; List theGreenSide = [null, null, null]; // TetrioPlayer?, List>?, TetraLeagueAlpha? Mode redSideMode = Mode.player; List theRedSide = [null, null, null]; -final TetrioService teto = TetrioService(); final DateFormat dateFormat = DateFormat.yMd(LocaleSettings.currentLocale.languageCode).add_Hm(); var numbersReg = RegExp(r'\d+(\.\d*)*'); late String oldWindowTitle; diff --git a/lib/views/customization_view.dart b/lib/views/customization_view.dart index 4d85148..f518c8a 100644 --- a/lib/views/customization_view.dart +++ b/lib/views/customization_view.dart @@ -1,7 +1,9 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:shared_preferences/shared_preferences.dart'; +import 'package:flutter_colorpicker/flutter_colorpicker.dart'; +import 'package:tetra_stats/views/settings_view.dart' show subtitleStyle; +import 'package:tetra_stats/main.dart' show MyAppState, prefs; import 'package:tetra_stats/gen/strings.g.dart'; import 'package:window_manager/window_manager.dart'; @@ -17,8 +19,10 @@ class CustomizationView extends StatefulWidget { } class CustomizationState extends State { - late SharedPreferences prefs; late bool oskKagariGimmick; + late bool sheetbotRadarGraphs; + late int ratingMode; + late int timestampMode; void changeColor(Color color) { setState(() => pickerColor = color); @@ -30,7 +34,7 @@ class CustomizationState extends State { windowManager.getTitle().then((value) => oldWindowTitle = value); windowManager.setTitle("Tetra Stats: ${t.settings}"); } - _getPreferences().then((value) => setState((){})); + _getPreferences(); super.initState(); } @@ -40,13 +44,27 @@ class CustomizationState extends State { super.dispose(); } - Future _getPreferences() async { - prefs = await SharedPreferences.getInstance(); + void _getPreferences() { if (prefs.getBool("oskKagariGimmick") != null) { oskKagariGimmick = prefs.getBool("oskKagariGimmick")!; } else { oskKagariGimmick = true; } + if (prefs.getBool("sheetbotRadarGraphs") != null) { + sheetbotRadarGraphs = prefs.getBool("sheetbotRadarGraphs")!; + } else { + sheetbotRadarGraphs = false; + } + if (prefs.getInt("ratingMode") != null) { + ratingMode = prefs.getInt("ratingMode")!; + } else { + ratingMode = 0; + } + if (prefs.getInt("timestampMode") != null) { + timestampMode = prefs.getInt("timestampMode")!; + } else { + timestampMode = 0; + } } ThemeData getTheme(BuildContext context, Color color){ @@ -64,48 +82,89 @@ class CustomizationState extends State { } return Scaffold( appBar: AppBar( - title: Text(t.settings), + title: Text(t.customization), ), backgroundColor: Colors.black, body: SafeArea( child: ListView( children: [ - // ListTile( - // title: const Text("Accent color"), - // trailing: ColorIndicator(HSVColor.fromColor(Theme.of(context).colorScheme.primary)), - // onTap: () { - // showDialog( - // context: context, - // builder: (BuildContext context) => AlertDialog( - // title: const Text('Pick an accent color'), - // content: SingleChildScrollView( - // child: ColorPicker( - // pickerColor: pickerColor, - // onColorChanged: changeColor, - // ), - // ), - // actions: [ - // ElevatedButton( - // child: const Text('Set'), - // onPressed: () { - // setState(() { - // setAccentColor(pickerColor); - // }); - // Navigator.of(context).pop(); - // }, - // ), - // ])); - // }), - // const ListTile( - // title: Text("Font"), - // subtitle: Text("Not implemented"), - // ), + ListTile( + title: Text(t.AccentColor), + subtitle: Text(t.AccentColorDescription, style: subtitleStyle), + trailing: ColorIndicator(HSVColor.fromColor(Theme.of(context).colorScheme.primary), width: 25, height: 25), + onTap: () { + showDialog( + context: context, + builder: (BuildContext context) => AlertDialog( + title: const Text('Pick an accent color'), + content: SingleChildScrollView( + child: ColorPicker( + pickerColor: pickerColor, + onColorChanged: changeColor, + ), + ), + actions: [ + ElevatedButton( + child: const Text('Set'), + onPressed: () { + setState(() { + context.findAncestorStateOfType()?.setAccentColor(pickerColor); + prefs.setInt("accentColor", pickerColor.value); + }); + Navigator.of(context).pop(); + }, + ), + ])); + } + ), // const ListTile( // title: Text("Stats Table in TL mathes list"), // subtitle: Text("Not implemented"), // ), - ListTile(title: Text(t.oskKagari), - subtitle: Text(t.oskKagariDescription), + ListTile(title: Text(t.timestamps), + subtitle: Text(t.timestampsDescription, style: subtitleStyle), + trailing: DropdownButton( + value: timestampMode, + items: [ + DropdownMenuItem(value: 0, child: Text(t.timestampsAbsoluteGMT)), + DropdownMenuItem(value: 1, child: Text(t.timestampsAbsoluteLocalTime)), + DropdownMenuItem(value: 2, child: Text(t.timestampsRelative)) + ], + onChanged: (dynamic value){ + prefs.setInt("timestampMode", value); + setState(() { + timestampMode = value; + }); + }, + ), + ), + ListTile(title: Text(t.rating), + subtitle: Text(t.ratingDescription, style: subtitleStyle), + trailing: DropdownButton( + value: ratingMode, + items: [ + const DropdownMenuItem(value: 0, child: Text("TR")), + const DropdownMenuItem(value: 1, child: Text("Glicko")), + DropdownMenuItem(value: 2, child: Text(t.ratingLBposition)) + ], + onChanged: (dynamic value){ + prefs.setInt("ratingMode", value); + setState(() { + ratingMode = value; + }); + }, + ), + ), + ListTile(title: Text(t.sheetbotGraphs), + subtitle: Text(t.sheetbotGraphsDescription, style: subtitleStyle), + trailing: Switch(value: sheetbotRadarGraphs, onChanged: (bool value){ + prefs.setBool("sheetbotRadarGraphs", value); + setState(() { + sheetbotRadarGraphs = value; + }); + }),), + ListTile(title: Text(t.oskKagari), + subtitle: Text(t.oskKagariDescription, style: subtitleStyle), trailing: Switch(value: oskKagariGimmick, onChanged: (bool value){ prefs.setBool("oskKagariGimmick", value); setState(() { diff --git a/lib/views/main_view.dart b/lib/views/main_view.dart index 5cb42e1..8680e59 100644 --- a/lib/views/main_view.dart +++ b/lib/views/main_view.dart @@ -1,5 +1,6 @@ -// ignore_for_file: type_literal_in_constant_pattern +// ignore_for_file: type_literal_in_constant_pattern, use_build_context_synchronously +import 'dart:async'; import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; @@ -10,39 +11,38 @@ import 'package:intl/intl.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:flutter/services.dart'; import 'package:syncfusion_flutter_charts/charts.dart'; +import 'package:tetra_stats/data_objects/tetra_stats.dart'; import 'package:tetra_stats/data_objects/tetrio.dart'; import 'package:tetra_stats/gen/strings.g.dart'; -import 'package:tetra_stats/services/tetrio_crud.dart'; -import 'package:tetra_stats/main.dart' show prefs; +import 'package:tetra_stats/main.dart' show prefs, teto; import 'package:tetra_stats/services/crud_exceptions.dart'; import 'package:tetra_stats/utils/numers_formats.dart'; +import 'package:tetra_stats/utils/open_in_browser.dart'; +import 'package:tetra_stats/utils/relative_timestamps.dart'; import 'package:tetra_stats/utils/text_shadow.dart'; -import 'package:tetra_stats/views/ranks_averages_view.dart' show RankAveragesView; -import 'package:tetra_stats/views/sprint_and_blitz_averages.dart'; -import 'package:tetra_stats/views/tl_leaderboard_view.dart' show TLLeaderboardView; +import 'package:tetra_stats/views/singleplayer_record_view.dart'; import 'package:tetra_stats/views/tl_match_view.dart' show TlMatchResultView; import 'package:tetra_stats/widgets/finesse_thingy.dart'; import 'package:tetra_stats/widgets/lineclears_thingy.dart'; import 'package:tetra_stats/widgets/list_tile_trailing_stats.dart'; +import 'package:tetra_stats/widgets/recent_sp_games.dart'; import 'package:tetra_stats/widgets/search_box.dart'; +import 'package:tetra_stats/widgets/singleplayer_record.dart'; +import 'package:tetra_stats/widgets/sp_trailing_stats.dart'; import 'package:tetra_stats/widgets/stat_sell_num.dart'; +import 'package:tetra_stats/widgets/text_timestamp.dart'; import 'package:tetra_stats/widgets/tl_thingy.dart'; import 'package:tetra_stats/widgets/user_thingy.dart'; import 'package:window_manager/window_manager.dart'; import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:go_router/go_router.dart'; -final TetrioService teto = TetrioService(); // thing, that manadge our local DB int _chartsIndex = 0; bool _gamesPlayedInsteadOfDateAndTime = false; late ZoomPanBehavior _zoomPanBehavior; bool _smooth = false; List _historyShortTitles = ["TR", "Glicko", "RD", "APM", "PPS", "VS", "APP", "DS/S", "DS/P", "APP + DS/P", "VS/APM", "Cheese", "GbE", "wAPP", "Area", "eTR", "±eTR", "Opener", "Plonk", "Inf. DS", "Stride"]; late ScrollController _scrollController; -final NumberFormat _timeInSec = NumberFormat("#,###.###s.", LocaleSettings.currentLocale.languageCode); -final NumberFormat secs = NumberFormat("00.###", LocaleSettings.currentLocale.languageCode); -final DateFormat _dateFormat = DateFormat.yMMMd(LocaleSettings.currentLocale.languageCode).add_Hms(); - class MainView extends StatefulWidget { final String? player; @@ -59,25 +59,6 @@ Future copyToClipboard(String text) async { await Clipboard.setData(ClipboardData(text: text)); } -/// Takes number of [microseconds] and returns readable 40 lines time -String get40lTime(int microseconds){ - return microseconds > 60000000 ? "${(microseconds/1000000/60).floor()}:${(secs.format(microseconds /1000000 % 60))}" : _timeInSec.format(microseconds / 1000000); - } - -/// Readable [a] - [b], without sign -String readableTimeDifference(Duration a, Duration b){ - Duration result = a - b; - - return NumberFormat("0.000s;0.000s", LocaleSettings.currentLocale.languageCode).format(result.inMilliseconds/1000); -} - -/// Readable [a] - [b], without sign -String readableIntDifference(int a, int b){ - int result = a - b; - - return NumberFormat("#,###;#,###", LocaleSettings.currentLocale.languageCode).format(result); -} - class _MainState extends State with TickerProviderStateMixin { Future me = Future.delayed(const Duration(seconds: 60), () => [null, null, null, null, null, null]); // I love lists shut up TetrioPlayersLeaderboard? everyone; @@ -94,10 +75,10 @@ class _MainState extends State with TickerProviderStateMixin { //var tableData = []; final bodyGlobalKey = GlobalKey(); bool _showSearchBar = false; + Timer backgroundUpdate = Timer(const Duration(days: 365), (){}); bool _TLHistoryWasFetched = false; late TabController _tabController; late TabController _wideScreenTabController; - late bool fixedScroll; String get title => "Tetra Stats: $_titleNickname"; @@ -105,7 +86,7 @@ class _MainState extends State with TickerProviderStateMixin { void initState() { initDB(); _scrollController = ScrollController(); - _tabController = TabController(length: 6, vsync: this); + _tabController = TabController(length: 7, vsync: this); _wideScreenTabController = TabController(length: 4, vsync: this); _zoomPanBehavior = ZoomPanBehavior( enablePinching: true, @@ -158,6 +139,7 @@ class _MainState extends State with TickerProviderStateMixin { Future fetch(String nickOrID, {bool fetchHistory = false, bool fetchTLmatches = false}) async { TetrioPlayer me; _TLHistoryWasFetched = false; + backgroundUpdate.cancel(); // If user trying to search with discord id if (nickOrID.startsWith("ds:")){ @@ -174,23 +156,32 @@ class _MainState extends State with TickerProviderStateMixin { // Requesting Tetra League (alpha), records, news and top TR of player late List requests; late TetraLeagueAlphaStream tlStream; - late Map records; - late List news; + late UserRecords records; + late News news; + late SingleplayerStream recent; + late SingleplayerStream sprint; + late SingleplayerStream blitz; late TetrioPlayerFromLeaderboard? topOne; - late double? topTR; - requests = await Future.wait([ // all at once + late TopTr? topTR; + requests = await Future.wait([ // all at once (7 requests to oskware lmao) teto.fetchTLStream(_searchFor), teto.fetchRecords(_searchFor), teto.fetchNews(_searchFor), + teto.fetchSingleplayerStream(_searchFor, "any_userrecent"), + teto.fetchSingleplayerStream(_searchFor, "40l_userbest"), + teto.fetchSingleplayerStream(_searchFor, "blitz_userbest"), prefs.getBool("showPositions") != true ? teto.fetchCutoffs() : Future.delayed(Duration.zero, ()=>>[]), (me.tlSeason1.rank != "z" ? me.tlSeason1.rank == "x" : me.tlSeason1.percentileRank == "x") ? teto.fetchTopOneFromTheLeaderboard() : Future.delayed(Duration.zero, ()=>null), - if (me.tlSeason1.gamesPlayed > 9) teto.fetchTopTR(_searchFor) // can retrieve this only if player has TR + (me.tlSeason1.gamesPlayed > 9) ? teto.fetchTopTR(_searchFor) : Future.delayed(Duration.zero, () => null) // can retrieve this only if player has TR ]); tlStream = requests[0] as TetraLeagueAlphaStream; - records = requests[1] as Map; - news = requests[2] as List; - topOne = requests[4] as TetrioPlayerFromLeaderboard?; - topTR = requests.elementAtOrNull(5) as double?; // No TR - no Top TR + records = requests[1] as UserRecords; + news = requests[2] as News; + recent = requests[3] as SingleplayerStream; + sprint = requests[4] as SingleplayerStream; + blitz = requests[5] as SingleplayerStream; + topOne = requests[7] as TetrioPlayerFromLeaderboard?; + topTR = requests[8] as TopTr?; // No TR - no Top TR meAmongEveryone = teto.getCachedLeaderboardPositions(me.userId); if (prefs.getBool("showPositions") == true){ @@ -202,8 +193,8 @@ class _MainState extends State with TickerProviderStateMixin { if (meAmongEveryone != null) teto.cacheLeaderboardPositions(me.userId, meAmongEveryone!); } } - Map? cutoffs = prefs.getBool("showPositions") == true ? everyone!.cutoffs : (requests[3] as List>).elementAtOrNull(0); - Map? cutoffsGlicko = prefs.getBool("showPositions") == true ? everyone!.cutoffsGlicko : (requests[3] as List>).elementAtOrNull(1); + Map? cutoffs = prefs.getBool("showPositions") == true ? everyone!.cutoffs : (requests[6] as Cutoffs?)?.tr; + Map? cutoffsGlicko = prefs.getBool("showPositions") == true ? everyone!.cutoffsGlicko : (requests[6] as Cutoffs?)?.glicko; if (me.tlSeason1.gamesPlayed > 9) { thatRankCutoff = cutoffs?[me.tlSeason1.rank != "z" ? me.tlSeason1.rank : me.tlSeason1.percentileRank]; @@ -308,7 +299,14 @@ class _MainState extends State with TickerProviderStateMixin { compareWith = null; chartsData = []; } - return [me, records, states, tlMatches, compareWith, isTracking, news, topTR]; + + if (prefs.getBool("updateInBG") == true) { + backgroundUpdate = Timer(me.cachedUntil!.difference(DateTime.now()), () { + changePlayer(me.userId); + }); + } + + return [me, records, states, tlMatches, compareWith, isTracking, news, topTR, recent, sprint, blitz]; } /// Triggers widgets rebuild @@ -440,6 +438,7 @@ class _MainState extends State with TickerProviderStateMixin { Tab(text: t.history), Tab(text: t.sprint), Tab(text: t.blitz), + Tab(text: t.recentRuns), Tab(text: t.other), ], ), @@ -459,7 +458,7 @@ class _MainState extends State with TickerProviderStateMixin { tl: snapshot.data![0].tlSeason1, userID: snapshot.data![0].userId, states: snapshot.data![2], - topTR: snapshot.data![7], + topTR: snapshot.data![7]?.tr, bot: snapshot.data![0].role == "bot", guest: snapshot.data![0].role == "anon", thatRankCutoff: thatRankCutoff, @@ -478,14 +477,14 @@ class _MainState extends State with TickerProviderStateMixin { ), ],), _History(chartsData: chartsData, changePlayer: changePlayer, userID: _searchFor, update: _justUpdate, wasActiveInTL: snapshot.data![0].tlSeason1.gamesPlayed > 0), - _TwoRecordsThingy(sprint: snapshot.data![1]['sprint'], blitz: snapshot.data![1]['blitz'], rank: snapshot.data![0].tlSeason1.percentileRank,), - _OtherThingy(zen: snapshot.data![1]['zen'], bio: snapshot.data![0].bio, distinguishment: snapshot.data![0].distinguishment, newsletter: snapshot.data![6],) + _TwoRecordsThingy(sprint: snapshot.data![1].sprint, blitz: snapshot.data![1].blitz, rank: snapshot.data![0].tlSeason1.percentileRank, recent: snapshot.data![8], sprintStream: snapshot.data![9], blitzStream: snapshot.data![10]), + _OtherThingy(zen: snapshot.data![1].zen, bio: snapshot.data![0].bio, distinguishment: snapshot.data![0].distinguishment, newsletter: snapshot.data![6],) ] : [ TLThingy( tl: snapshot.data![0].tlSeason1, userID: snapshot.data![0].userId, states: snapshot.data![2], - topTR: snapshot.data![7], + topTR: snapshot.data![7]?.tr, bot: snapshot.data![0].role == "bot", guest: snapshot.data![0].role == "anon", thatRankCutoff: thatRankCutoff, @@ -499,9 +498,10 @@ class _MainState extends State with TickerProviderStateMixin { ), _TLRecords(userID: snapshot.data![0].userId, changePlayer: changePlayer, data: snapshot.data![3], wasActiveInTL: snapshot.data![0].tlSeason1.gamesPlayed > 0, oldMathcesHere: _TLHistoryWasFetched), _History(chartsData: chartsData, changePlayer: changePlayer, userID: _searchFor, update: _justUpdate, wasActiveInTL: snapshot.data![0].tlSeason1.gamesPlayed > 0), - _RecordThingy(record: snapshot.data![1]['sprint'], rank: snapshot.data![0].tlSeason1.percentileRank), - _RecordThingy(record: snapshot.data![1]['blitz'], rank: snapshot.data![0].tlSeason1.percentileRank), - _OtherThingy(zen: snapshot.data![1]['zen'], bio: snapshot.data![0].bio, distinguishment: snapshot.data![0].distinguishment, newsletter: snapshot.data![6],) + SingleplayerRecord(record: snapshot.data![1].sprint, rank: snapshot.data![0].tlSeason1.percentileRank, stream: snapshot.data![9]), + SingleplayerRecord(record: snapshot.data![1].blitz, rank: snapshot.data![0].tlSeason1.percentileRank, stream: snapshot.data![10]), + _RecentSingleplayersThingy(snapshot.data![8]), + _OtherThingy(zen: snapshot.data![1].zen, bio: snapshot.data![0].bio, distinguishment: snapshot.data![0].distinguishment, newsletter: snapshot.data![6]) ], ), ), @@ -643,12 +643,7 @@ class _NavDrawerState extends State { leading: const Icon(Icons.leaderboard), title: Text(t.tlLeaderboard), onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => const TLLeaderboardView(), - ), - ); + context.go("/leaderboard"); }, ), ), @@ -657,12 +652,7 @@ class _NavDrawerState extends State { leading: const Icon(Icons.compress), title: Text(t.rankAveragesViewTitle), onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => const RankAveragesView(), - ), - ); + context.go("/LBvalues"); }, ), ), @@ -671,12 +661,7 @@ class _NavDrawerState extends State { leading: const Icon(Icons.bar_chart), title: Text(t.sprintAndBlitsViewTitle), onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => const SprintAndBlitzView(), - ), - ); + context.go("/sprintAndBlitzAverages"); }, ), ), @@ -758,7 +743,7 @@ class _TLRecords extends StatelessWidget { leading: Text("${data[index].endContext.firstWhere((element) => element.userId == userID).points} : ${data[index].endContext.firstWhere((element) => element.userId != userID).points}", style: bigScreen ? const TextStyle(fontFamily: "Eurostile Round Extended", fontSize: 28, shadows: textShadow) : const TextStyle(fontSize: 28, shadows: textShadow)), title: Text("vs. ${data[index].endContext.firstWhere((element) => element.userId != userID).username}"), - subtitle: Text(_dateFormat.format(data[index].timestamp)), + subtitle: Text(timestamp(data[index].timestamp), style: const TextStyle(color: Colors.grey)), trailing: TrailingStats( data[index].endContext.firstWhere((element) => element.userId == userID).secondary, data[index].endContext.firstWhere((element) => element.userId == userID).tertiary, @@ -921,7 +906,7 @@ class _HistoryChartThigyState extends State<_HistoryChartThigy> { style: const TextStyle(fontFamily: "Eurostile Round", fontSize: 20), ), ), - Text(_gamesPlayedInsteadOfDateAndTime ? t.gamesPlayed(games: t.games(n: data.gamesPlayed)) : _dateFormat.format(data.timestamp)) + Text(_gamesPlayedInsteadOfDateAndTime ? t.gamesPlayed(games: t.games(n: data.gamesPlayed)) : timestamp(data.timestamp)) ], ), ); @@ -964,39 +949,38 @@ class _HistoryChartThigyState extends State<_HistoryChartThigy> { primaryYAxis: const NumericAxis( rangePadding: ChartRangePadding.additional, ), + margin: const EdgeInsets.all(0), series: [ if (_gamesPlayedInsteadOfDateAndTime) StepLineSeries<_HistoryChartSpot, int>( enableTooltip: true, - // splineType: SplineType.cardinal, - // cardinalSplineTension: 0.2, dataSource: widget.data, animationDuration: 0, opacity: _smooth ? 0 : 1, xValueMapper: (_HistoryChartSpot data, _) => data.gamesPlayed, yValueMapper: (_HistoryChartSpot data, _) => data.stat, + color: Theme.of(context).colorScheme.primary, trendlines:[ Trendline( isVisible: _smooth, period: (widget.data.length/175).floor(), type: TrendlineType.movingAverage, - color: Colors.blue) + color: Theme.of(context).colorScheme.primary) ], ) else StepLineSeries<_HistoryChartSpot, DateTime>( enableTooltip: true, - // splineType: SplineType.cardinal, - // cardinalSplineTension: 0.2, dataSource: widget.data, animationDuration: 0, opacity: _smooth ? 0 : 1, xValueMapper: (_HistoryChartSpot data, _) => data.timestamp, yValueMapper: (_HistoryChartSpot data, _) => data.stat, + color: Theme.of(context).colorScheme.primary, trendlines:[ Trendline( isVisible: _smooth, period: (widget.data.length/175).floor(), type: TrendlineType.movingAverage, - color: Colors.blue) + color: Theme.of(context).colorScheme.primary) ], ), ], @@ -1010,9 +994,12 @@ class _HistoryChartThigyState extends State<_HistoryChartThigy> { class _TwoRecordsThingy extends StatelessWidget { final RecordSingle? sprint; final RecordSingle? blitz; + final SingleplayerStream recent; + final SingleplayerStream sprintStream; + final SingleplayerStream blitzStream; final String? rank; - const _TwoRecordsThingy({required this.sprint, required this.blitz, this.rank}); + const _TwoRecordsThingy({required this.sprint, required this.blitz, this.rank, required this.recent, required this.sprintStream, required this.blitzStream}); Color getColorOfRank(int rank){ if (rank == 1) return Colors.yellowAccent; @@ -1028,23 +1015,23 @@ class _TwoRecordsThingy extends StatelessWidget { //if (record == null) return Center(child: Text(t.noRecord, textAlign: TextAlign.center, style: const TextStyle(fontFamily: "Eurostile Round", fontSize: 28))); late MapEntry closestAverageBlitz; late bool blitzBetterThanClosestAverage; - bool? blitzBetterThanRankAverage = (rank != null && rank != "z" && blitz != null) ? blitz!.endContext!.score > blitzAverages[rank]! : null; + bool? blitzBetterThanRankAverage = (rank != null && rank != "z" && blitz != null) ? blitz!.endContext.score > blitzAverages[rank]! : null; late MapEntry closestAverageSprint; late bool sprintBetterThanClosestAverage; - bool? sprintBetterThanRankAverage = (rank != null && rank != "z" && sprint != null) ? sprint!.endContext!.finalTime < sprintAverages[rank]! : null; + bool? sprintBetterThanRankAverage = (rank != null && rank != "z" && sprint != null) ? sprint!.endContext.finalTime < sprintAverages[rank]! : null; if (sprint != null) { - closestAverageSprint = sprintAverages.entries.singleWhere((element) => element.value == sprintAverages.values.reduce((a, b) => (a-sprint!.endContext!.finalTime).abs() < (b -sprint!.endContext!.finalTime).abs() ? a : b)); - sprintBetterThanClosestAverage = sprint!.endContext!.finalTime < closestAverageSprint.value; + closestAverageSprint = sprintAverages.entries.singleWhere((element) => element.value == sprintAverages.values.reduce((a, b) => (a-sprint!.endContext.finalTime).abs() < (b -sprint!.endContext.finalTime).abs() ? a : b)); + sprintBetterThanClosestAverage = sprint!.endContext.finalTime < closestAverageSprint.value; } if (blitz != null){ - closestAverageBlitz = blitzAverages.entries.singleWhere((element) => element.value == blitzAverages.values.reduce((a, b) => (a-blitz!.endContext!.score).abs() < (b -blitz!.endContext!.score).abs() ? a : b)); - blitzBetterThanClosestAverage = blitz!.endContext!.score > closestAverageBlitz.value; + closestAverageBlitz = blitzAverages.entries.singleWhere((element) => element.value == blitzAverages.values.reduce((a, b) => (a-blitz!.endContext.score).abs() < (b -blitz!.endContext.score).abs() ? a : b)); + blitzBetterThanClosestAverage = blitz!.endContext.score > closestAverageBlitz.value; } return SingleChildScrollView(child: Padding( padding: const EdgeInsets.only(top: 20.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - crossAxisAlignment: CrossAxisAlignment.start, + child: Wrap( + alignment: WrapAlignment.spaceEvenly, + crossAxisAlignment: WrapCrossAlignment.start, children: [ Column( mainAxisAlignment: MainAxisAlignment.start, @@ -1060,24 +1047,24 @@ class _TwoRecordsThingy extends StatelessWidget { children: [ Text(t.sprint, style: const TextStyle(height: 0.1, fontFamily: "Eurostile Round Extended", fontSize: 18)), RichText(text: TextSpan( - text: sprint != null ? get40lTime(sprint!.endContext!.finalTime.inMicroseconds) : "---", + text: sprint != null ? get40lTime(sprint!.endContext.finalTime.inMicroseconds) : "---", style: TextStyle(fontFamily: "Eurostile Round Extended", fontSize: 36, fontWeight: FontWeight.w500, color: sprint != null ? Colors.white : Colors.grey), - //children: [TextSpan(text: get40lTime(record!.endContext!.finalTime.inMicroseconds), style: TextStyle(fontFamily: "Eurostile Round", fontSize: 14, fontWeight: FontWeight.w100))] + //children: [TextSpan(text: get40lTime(record!.endContext.finalTime.inMicroseconds), style: TextStyle(fontFamily: "Eurostile Round", fontSize: 14, fontWeight: FontWeight.w100))] ), ), if (sprint != null) RichText(text: TextSpan( text: "", style: const TextStyle(fontFamily: "Eurostile Round", fontSize: 14, color: Colors.grey), children: [ - if (rank != null && rank != "z") TextSpan(text: "${t.verdictGeneral(n: readableTimeDifference(sprint!.endContext!.finalTime, sprintAverages[rank]!), verdict: sprintBetterThanRankAverage??false ? t.verdictBetter : t.verdictWorse, rank: rank!.toUpperCase())}\n", style: TextStyle( + if (rank != null && rank != "z") TextSpan(text: "${t.verdictGeneral(n: readableTimeDifference(sprint!.endContext.finalTime, sprintAverages[rank]!), verdict: sprintBetterThanRankAverage??false ? t.verdictBetter : t.verdictWorse, rank: rank!.toUpperCase())}\n", style: TextStyle( color: sprintBetterThanRankAverage??false ? Colors.greenAccent : Colors.redAccent )) - else TextSpan(text: "${t.verdictGeneral(n: readableTimeDifference(sprint!.endContext!.finalTime, closestAverageSprint.value), verdict: sprintBetterThanClosestAverage ? t.verdictBetter : t.verdictWorse, rank: closestAverageSprint.key.toUpperCase())}\n", style: TextStyle( + else TextSpan(text: "${t.verdictGeneral(n: readableTimeDifference(sprint!.endContext.finalTime, closestAverageSprint.value), verdict: sprintBetterThanClosestAverage ? t.verdictBetter : t.verdictWorse, rank: closestAverageSprint.key.toUpperCase())}\n", style: TextStyle( color: sprintBetterThanClosestAverage ? Colors.greenAccent : Colors.redAccent )), if (sprint!.rank != null) TextSpan(text: "№${sprint!.rank}", style: TextStyle(color: getColorOfRank(sprint!.rank!))), if (sprint!.rank != null) const TextSpan(text: " • "), - TextSpan(text: _dateFormat.format(sprint!.timestamp!)), + TextSpan(text: timestamp(sprint!.timestamp)), ] ), ), @@ -1089,14 +1076,39 @@ class _TwoRecordsThingy extends StatelessWidget { alignment: WrapAlignment.spaceBetween, spacing: 20, children: [ - StatCellNum(playerStat: sprint!.endContext!.piecesPlaced, playerStatLabel: t.statCellNum.pieces, isScreenBig: true, higherIsBetter: true, smallDecimal: false), - StatCellNum(playerStat: sprint!.endContext!.pps, playerStatLabel: t.statCellNum.pps, fractionDigits: 2, isScreenBig: true, higherIsBetter: true, smallDecimal: false), - StatCellNum(playerStat: sprint!.endContext!.kpp, playerStatLabel: t.statCellNum.kpp, fractionDigits: 2, isScreenBig: true, higherIsBetter: true, smallDecimal: false), + StatCellNum(playerStat: sprint!.endContext.piecesPlaced, playerStatLabel: t.statCellNum.pieces, isScreenBig: true, higherIsBetter: true, smallDecimal: false), + StatCellNum(playerStat: sprint!.endContext.pps, playerStatLabel: t.statCellNum.pps, fractionDigits: 2, isScreenBig: true, higherIsBetter: true, smallDecimal: false), + StatCellNum(playerStat: sprint!.endContext.kpp, playerStatLabel: t.statCellNum.kpp, fractionDigits: 2, isScreenBig: true, higherIsBetter: true, smallDecimal: false), ], ), - if (sprint != null) FinesseThingy(sprint?.endContext?.finesse, sprint?.endContext?.finessePercentage), - if (sprint != null) LineclearsThingy(sprint!.endContext!.clears, sprint!.endContext!.lines, sprint!.endContext!.holds, sprint!.endContext!.tSpins), - if (sprint != null) Text("${sprint!.endContext!.inputs} KP • ${f2.format(sprint!.endContext!.kps)} KPS") + if (sprint != null) FinesseThingy(sprint?.endContext.finesse, sprint?.endContext.finessePercentage), + if (sprint != null) LineclearsThingy(sprint!.endContext.clears, sprint!.endContext.lines, sprint!.endContext.holds, sprint!.endContext.tSpins), + if (sprint != null) Text("${sprint!.endContext.inputs} KP • ${f2.format(sprint!.endContext.kps)} KPS"), + Wrap( + alignment: WrapAlignment.spaceBetween, + crossAxisAlignment: WrapCrossAlignment.start, + spacing: 20, + children: [ + TextButton(onPressed: (){launchInBrowser(Uri.parse("https://tetr.io/#r:${sprint!.replayId}"));}, child: Text(t.openSPreplay)), + TextButton(onPressed: (){launchInBrowser(Uri.parse("https://inoue.szy.lol/api/replay/${sprint!.replayId}"));}, child: Text(t.downloadSPreplay)), + ], + ), + if (sprintStream.records.length > 1) SizedBox( + width: 400, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (int i = 1; i < sprintStream.records.length; i++) ListTile( + onTap: () => Navigator.push(context, MaterialPageRoute(builder: (context) => SingleplayerRecordView(record: sprintStream.records[i]))), + leading: Text("#${i+1}", style: const TextStyle(fontFamily: "Eurostile Round", fontSize: 28, shadows: textShadow, height: 0.9) ), + title: Text(get40lTime(sprintStream.records[i].endContext.finalTime.inMicroseconds), + style: const TextStyle(fontSize: 18)), + subtitle: Text(timestamp(sprintStream.records[i].timestamp), style: const TextStyle(color: Colors.grey, height: 0.85)), + trailing: SpTrailingStats(sprintStream.records[i].endContext) + ) + ], + ), + ) ] ), Column( @@ -1116,7 +1128,7 @@ class _TwoRecordsThingy extends StatelessWidget { text: "", style: const TextStyle(fontFamily: "Eurostile Round Extended", fontSize: 36, fontWeight: FontWeight.w500, color: Colors.white), children: [ - TextSpan(text: blitz != null ? NumberFormat.decimalPattern().format(blitz!.endContext!.score) : "---"), + TextSpan(text: blitz != null ? NumberFormat.decimalPattern().format(blitz!.endContext.score) : "---"), //WidgetSpan(child: Image.asset("res/icons/kagari.png", height: 48)) ] ), @@ -1127,13 +1139,13 @@ class _TwoRecordsThingy extends StatelessWidget { text: "", style: const TextStyle(fontFamily: "Eurostile Round", fontSize: 14, color: Colors.grey), children: [ - if (rank != null && rank != "z") TextSpan(text: "${t.verdictGeneral(n: readableIntDifference(blitz!.endContext!.score, blitzAverages[rank]!), verdict: blitzBetterThanRankAverage??false ? t.verdictBetter : t.verdictWorse, rank: rank!.toUpperCase())}\n", style: TextStyle( + if (rank != null && rank != "z") TextSpan(text: "${t.verdictGeneral(n: readableIntDifference(blitz!.endContext.score, blitzAverages[rank]!), verdict: blitzBetterThanRankAverage??false ? t.verdictBetter : t.verdictWorse, rank: rank!.toUpperCase())}\n", style: TextStyle( color: blitzBetterThanRankAverage??false ? Colors.greenAccent : Colors.redAccent )) - else TextSpan(text: "${t.verdictGeneral(n: readableIntDifference(blitz!.endContext!.score, closestAverageBlitz.value), verdict: blitzBetterThanClosestAverage ? t.verdictBetter : t.verdictWorse, rank: closestAverageBlitz.key.toUpperCase())}\n", style: TextStyle( + else TextSpan(text: "${t.verdictGeneral(n: readableIntDifference(blitz!.endContext.score, closestAverageBlitz.value), verdict: blitzBetterThanClosestAverage ? t.verdictBetter : t.verdictWorse, rank: closestAverageBlitz.key.toUpperCase())}\n", style: TextStyle( color: blitzBetterThanClosestAverage ? Colors.greenAccent : Colors.redAccent )), - TextSpan(text: _dateFormat.format(blitz!.timestamp!)), + TextSpan(text: timestamp(blitz!.timestamp)), if (blitz!.rank != null) const TextSpan(text: " • "), if (blitz!.rank != null) TextSpan(text: "№${blitz!.rank}", style: TextStyle(color: getColorOfRank(blitz!.rank!))), ] @@ -1150,145 +1162,69 @@ class _TwoRecordsThingy extends StatelessWidget { crossAxisAlignment: WrapCrossAlignment.start, spacing: 20, children: [ - StatCellNum(playerStat: blitz!.endContext!.level, playerStatLabel: t.statCellNum.level, isScreenBig: true, higherIsBetter: true, smallDecimal: false), - StatCellNum(playerStat: blitz!.endContext!.pps, playerStatLabel: t.statCellNum.pps, fractionDigits: 2, isScreenBig: true, higherIsBetter: true, smallDecimal: false), - StatCellNum(playerStat: blitz!.endContext!.spp, playerStatLabel: t.statCellNum.spp, fractionDigits: 2, isScreenBig: true, higherIsBetter: true) + StatCellNum(playerStat: blitz!.endContext.level, playerStatLabel: t.statCellNum.level, isScreenBig: true, higherIsBetter: true, smallDecimal: false), + StatCellNum(playerStat: blitz!.endContext.pps, playerStatLabel: t.statCellNum.pps, fractionDigits: 2, isScreenBig: true, higherIsBetter: true, smallDecimal: false), + StatCellNum(playerStat: blitz!.endContext.spp, playerStatLabel: t.statCellNum.spp, fractionDigits: 2, isScreenBig: true, higherIsBetter: true) ], ), - if (blitz != null) FinesseThingy(blitz?.endContext?.finesse, blitz?.endContext?.finessePercentage), - if (blitz != null) LineclearsThingy(blitz!.endContext!.clears, blitz!.endContext!.lines, blitz!.endContext!.holds, blitz!.endContext!.tSpins), - if (blitz != null) Text("${blitz!.endContext!.piecesPlaced} P • ${blitz!.endContext!.inputs} KP • ${f2.format(blitz!.endContext!.kpp)} KPP • ${f2.format(blitz!.endContext!.kps)} KPS") + if (blitz != null) FinesseThingy(blitz?.endContext.finesse, blitz?.endContext.finessePercentage), + if (blitz != null) LineclearsThingy(blitz!.endContext.clears, blitz!.endContext.lines, blitz!.endContext.holds, blitz!.endContext.tSpins), + if (blitz != null) Text("${blitz!.endContext.piecesPlaced} P • ${blitz!.endContext.inputs} KP • ${f2.format(blitz!.endContext.kpp)} KPP • ${f2.format(blitz!.endContext.kps)} KPS"), + Wrap( + alignment: WrapAlignment.spaceBetween, + crossAxisAlignment: WrapCrossAlignment.start, + spacing: 20, + children: [ + TextButton(onPressed: (){launchInBrowser(Uri.parse("https://tetr.io/#r:${blitz!.replayId}"));}, child: Text(t.openSPreplay)), + TextButton(onPressed: (){launchInBrowser(Uri.parse("https://inoue.szy.lol/api/replay/${blitz!.replayId}"));}, child: Text(t.downloadSPreplay)), + ], + ), + if (blitzStream.records.length > 1) SizedBox( + width: 400, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (int i = 1; i < sprintStream.records.length; i++) ListTile( + onTap: () => Navigator.push(context, MaterialPageRoute(builder: (context) => SingleplayerRecordView(record: blitzStream.records[i]))), + leading: Text("#${i+1}", style: const TextStyle(fontFamily: "Eurostile Round", fontSize: 28, shadows: textShadow, height: 0.9) ), + title: Text("${NumberFormat.decimalPattern().format(blitzStream.records[i].endContext.score)} points", + style: const TextStyle(fontSize: 18)), + subtitle: Text(timestamp(blitzStream.records[i].timestamp), style: const TextStyle(color: Colors.grey, height: 0.85)), + trailing: SpTrailingStats(blitzStream.records[i].endContext) + ) + ], + ), + ) ], ), + SizedBox( + width: 400, + child: RecentSingleplayerGames(recent: recent), + ) ]), )); } } -class _RecordThingy extends StatelessWidget { - final RecordSingle? record; - final String? rank; +class _RecentSingleplayersThingy extends StatelessWidget { + final SingleplayerStream recent; - /// Widget that displays data from [record] - const _RecordThingy({required this.record, this.rank}); - - Color getColorOfRank(int rank){ - if (rank == 1) return Colors.yellowAccent; - if (rank == 2) return Colors.blueGrey; - if (rank == 3) return Colors.brown[400]!; - if (rank <= 9) return Colors.blueAccent; - if (rank <= 99) return Colors.greenAccent; - return Colors.grey; - } + const _RecentSingleplayersThingy(this.recent); @override Widget build(BuildContext context) { - if (record == null) return Center(child: Text(t.noRecord, textAlign: TextAlign.center, style: const TextStyle(fontFamily: "Eurostile Round", fontSize: 28))); - late MapEntry closestAverageBlitz; - late bool blitzBetterThanClosestAverage; - bool? blitzBetterThanRankAverage = (rank != null && rank != "z") ? record!.endContext!.score > blitzAverages[rank]! : null; - late MapEntry closestAverageSprint; - late bool sprintBetterThanClosestAverage; - bool? sprintBetterThanRankAverage = (rank != null && rank != "z") ? record!.endContext!.finalTime < sprintAverages[rank]! : null; - if (record!.stream.contains("40l")) { - closestAverageSprint = sprintAverages.entries.singleWhere((element) => element.value == sprintAverages.values.reduce((a, b) => (a-record!.endContext!.finalTime).abs() < (b -record!.endContext!.finalTime).abs() ? a : b)); - sprintBetterThanClosestAverage = record!.endContext!.finalTime < closestAverageSprint.value; - }else if (record!.stream.contains("blitz")){ - closestAverageBlitz = blitzAverages.entries.singleWhere((element) => element.value == blitzAverages.values.reduce((a, b) => (a-record!.endContext!.score).abs() < (b -record!.endContext!.score).abs() ? a : b)); - blitzBetterThanClosestAverage = record!.endContext!.score > closestAverageBlitz.value; - } - - return LayoutBuilder( - builder: (context, constraints) { - bool bigScreen = constraints.maxWidth > 768; - return SingleChildScrollView( - controller: _scrollController, - child: Padding( - padding: const EdgeInsets.only(top: 8.0), - child: Column( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (record!.stream.contains("40l")) Padding(padding: const EdgeInsets.only(right: 8.0), - child: Image.asset("res/tetrio_tl_alpha_ranks/${closestAverageSprint.key}.png", height: 96) - ), - if (record!.stream.contains("blitz")) Padding(padding: const EdgeInsets.only(right: 8.0), - child: Image.asset("res/tetrio_tl_alpha_ranks/${closestAverageBlitz.key}.png", height: 96) - ), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (record!.stream.contains("40l")) Text(t.sprint, style: const TextStyle(height: 0.1, fontFamily: "Eurostile Round Extended", fontSize: 18)), - if (record!.stream.contains("blitz")) Text(t.blitz, style: const TextStyle(height: 0.1, fontFamily: "Eurostile Round Extended", fontSize: 18)), - RichText(text: TextSpan( - text: record!.stream.contains("40l") ? get40lTime(record!.endContext!.finalTime.inMicroseconds) : NumberFormat.decimalPattern().format(record!.endContext!.score), - style: TextStyle(fontFamily: "Eurostile Round Extended", fontSize: bigScreen ? 36 : 32, fontWeight: FontWeight.w500, color: Colors.white), - ), - ), - RichText(text: TextSpan( - text: "", - style: const TextStyle(fontFamily: "Eurostile Round", fontSize: 14, color: Colors.grey), - children: [ - if (record!.stream.contains("40l") && (rank != null && rank != "z")) TextSpan(text: "${t.verdictGeneral(n: readableTimeDifference(record!.endContext!.finalTime, sprintAverages[rank]!), verdict: sprintBetterThanRankAverage??false ? t.verdictBetter : t.verdictWorse, rank: rank!.toUpperCase())}\n", style: TextStyle( - color: sprintBetterThanRankAverage??false ? Colors.greenAccent : Colors.redAccent - )) - else if (record!.stream.contains("40l") && (rank == null || rank == "z")) TextSpan(text: "${t.verdictGeneral(n: readableTimeDifference(record!.endContext!.finalTime, closestAverageSprint.value), verdict: sprintBetterThanClosestAverage ? t.verdictBetter : t.verdictWorse, rank: closestAverageSprint.key.toUpperCase())}\n", style: TextStyle( - color: sprintBetterThanClosestAverage ? Colors.greenAccent : Colors.redAccent - )) - else if (record!.stream.contains("blitz") && (rank != null && rank != "z")) TextSpan(text: "${t.verdictGeneral(n: readableIntDifference(record!.endContext!.score, blitzAverages[rank]!), verdict: blitzBetterThanRankAverage??false ? t.verdictBetter : t.verdictWorse, rank: rank!.toUpperCase())}\n", style: TextStyle( - color: blitzBetterThanRankAverage??false ? Colors.greenAccent : Colors.redAccent - )) - else if (record!.stream.contains("blitz") && (rank == null || rank == "z")) TextSpan(text: "${t.verdictGeneral(n: readableIntDifference(record!.endContext!.score, closestAverageBlitz.value), verdict: blitzBetterThanClosestAverage ? t.verdictBetter : t.verdictWorse, rank: closestAverageBlitz.key.toUpperCase())}\n", style: TextStyle( - color: blitzBetterThanClosestAverage ? Colors.greenAccent : Colors.redAccent - )), - if (record!.rank != null) TextSpan(text: "№${record!.rank}", style: TextStyle(color: getColorOfRank(record!.rank!))), - if (record!.rank != null) const TextSpan(text: " • "), - TextSpan(text: _dateFormat.format(record!.timestamp!)), - ] - ), - ) - ],), - ], - ), - if (record!.stream.contains("40l")) Wrap( - alignment: WrapAlignment.spaceBetween, - spacing: 20, - children: [ - StatCellNum(playerStat: record!.endContext!.piecesPlaced, playerStatLabel: t.statCellNum.pieces, isScreenBig: bigScreen, higherIsBetter: true, smallDecimal: false), - StatCellNum(playerStat: record!.endContext!.pps, playerStatLabel: t.statCellNum.pps, fractionDigits: 2, isScreenBig: bigScreen, higherIsBetter: true, smallDecimal: false), - StatCellNum(playerStat: record!.endContext!.kpp, playerStatLabel: t.statCellNum.kpp, fractionDigits: 2, isScreenBig: bigScreen, higherIsBetter: true, smallDecimal: false), - ], - ), - if (record!.stream.contains("blitz")) Wrap( - alignment: WrapAlignment.spaceBetween, - crossAxisAlignment: WrapCrossAlignment.start, - spacing: 20, - children: [ - StatCellNum(playerStat: record!.endContext!.level, playerStatLabel: t.statCellNum.level, isScreenBig: bigScreen, higherIsBetter: true, smallDecimal: false), - StatCellNum(playerStat: record!.endContext!.pps, playerStatLabel: t.statCellNum.pps, fractionDigits: 2, isScreenBig: bigScreen, higherIsBetter: true, smallDecimal: false), - StatCellNum(playerStat: record!.endContext!.spp, playerStatLabel: t.statCellNum.spp, fractionDigits: 2, isScreenBig: bigScreen, higherIsBetter: true) - ], - ), - FinesseThingy(record?.endContext?.finesse, record?.endContext?.finessePercentage), - LineclearsThingy(record!.endContext!.clears, record!.endContext!.lines, record!.endContext!.holds, record!.endContext!.tSpins), - if (record!.stream.contains("40l")) Text("${record!.endContext!.inputs} KP • ${f2.format(record!.endContext!.kps)} KPS"), - if (record!.stream.contains("blitz")) Text("${record!.endContext!.piecesPlaced} P • ${record!.endContext!.inputs} KP • ${f2.format(record!.endContext!.kpp)} KPP • ${f2.format(record!.endContext!.kps)} KPS") - ] - ), - ), - ); - } + return SingleChildScrollView( + child: RecentSingleplayerGames(recent: recent, hideTitle: true) ); } + } class _OtherThingy extends StatelessWidget { final TetrioZen? zen; final String? bio; final Distinguishment? distinguishment; - final List? newsletter; + final News? newsletter; /// Widget, that shows players [distinguishment], [bio], [zen] and [newsletter] const _OtherThingy({required this.zen, required this.bio, required this.distinguishment, this.newsletter}); @@ -1337,7 +1273,7 @@ class _OtherThingy extends StatelessWidget { } /// Handles [news] entry and returns widget that contains this entry - ListTile getNewsTile(News news){ + ListTile getNewsTile(NewsEntry news){ Map gametypes = { "40l": t.sprint, "blitz": t.blitz, @@ -1359,7 +1295,7 @@ class _OtherThingy extends StatelessWidget { ] ) ), - subtitle: Text(_dateFormat.format(news.timestamp)), + subtitle: Text(timestamp(news.timestamp)), ); case "personalbest": return ListTile( @@ -1374,7 +1310,7 @@ class _OtherThingy extends StatelessWidget { ] ) ), - subtitle: Text(_dateFormat.format(news.timestamp)), + subtitle: Text(timestamp(news.timestamp)), leading: Image.asset( "res/icons/improvement-local.png", height: 48, @@ -1396,7 +1332,7 @@ class _OtherThingy extends StatelessWidget { ] ) ), - subtitle: Text(_dateFormat.format(news.timestamp)), + subtitle: Text(timestamp(news.timestamp)), leading: Image.asset( "res/tetrio_badges/${news.data["type"]}.png", height: 48, @@ -1418,7 +1354,7 @@ class _OtherThingy extends StatelessWidget { ] ) ), - subtitle: Text(_dateFormat.format(news.timestamp)), + subtitle: Text(timestamp(news.timestamp)), leading: Image.asset( "res/tetrio_tl_alpha_ranks/${news.data["rank"]}.png", height: 48, @@ -1439,7 +1375,7 @@ class _OtherThingy extends StatelessWidget { ] ) ), - subtitle: Text(_dateFormat.format(news.timestamp)), + subtitle: Text(timestamp(news.timestamp)), leading: Image.asset( "res/icons/supporter-tag.png", height: 48, @@ -1460,7 +1396,7 @@ class _OtherThingy extends StatelessWidget { ] ) ), - subtitle: Text(_dateFormat.format(news.timestamp)), + subtitle: Text(timestamp(news.timestamp)), leading: Image.asset( "res/icons/supporter-tag.png", height: 48, @@ -1473,7 +1409,7 @@ class _OtherThingy extends StatelessWidget { default: // if type is unknown return ListTile( title: Text(t.newsParts.unknownNews(type: news.type)), - subtitle: Text(_dateFormat.format(news.timestamp)), + subtitle: Text(timestamp(news.timestamp)), ); } } @@ -1519,7 +1455,7 @@ class _OtherThingy extends StatelessWidget { ], ), ), - if (newsletter != null && newsletter!.isNotEmpty && showNewsTitle) + if (newsletter != null && newsletter!.news.isNotEmpty && showNewsTitle) Text(t.news, style: TextStyle(fontFamily: "Eurostile Round Extended", fontSize: bigScreen ? 42 : 28)), ], ); @@ -1535,9 +1471,9 @@ class _OtherThingy extends StatelessWidget { SizedBox(width: 450, child: getShit(context, true, false)), SizedBox(width: constraints.maxWidth - 450, child: ListView.builder( physics: const AlwaysScrollableScrollPhysics(), - itemCount: newsletter!.length+1, + itemCount: newsletter!.news.length+1, itemBuilder: (BuildContext context, int index) { - return index == 0 ? Center(child: Text(t.news, style: const TextStyle(fontFamily: "Eurostile Round Extended", fontSize: 42))) : getNewsTile(newsletter![index-1]); + return index == 0 ? Center(child: Text(t.news, style: const TextStyle(fontFamily: "Eurostile Round Extended", fontSize: 42))) : getNewsTile(newsletter!.news[index-1]); } )) ] @@ -1546,9 +1482,9 @@ class _OtherThingy extends StatelessWidget { else { return ListView.builder( physics: const AlwaysScrollableScrollPhysics(), - itemCount: newsletter!.length+1, + itemCount: newsletter!.news.length+1, itemBuilder: (BuildContext context, int index) { - return index == 0 ? getShit(context, bigScreen, true) : getNewsTile(newsletter![index-1]); + return index == 0 ? getShit(context, bigScreen, true) : getNewsTile(newsletter!.news[index-1]); }, ); } diff --git a/lib/views/mathes_view.dart b/lib/views/mathes_view.dart index 0a1c3f5..8f03a82 100644 --- a/lib/views/mathes_view.dart +++ b/lib/views/mathes_view.dart @@ -2,12 +2,11 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; -import 'package:tetra_stats/services/tetrio_crud.dart'; +import 'package:tetra_stats/main.dart' show teto; import 'package:tetra_stats/gen/strings.g.dart'; import 'package:tetra_stats/views/tl_match_view.dart'; import 'package:window_manager/window_manager.dart'; -final TetrioService teto = TetrioService(); late String oldWindowTitle; class MatchesView extends StatefulWidget { diff --git a/lib/views/ranks_averages_view.dart b/lib/views/ranks_averages_view.dart index e45e512..5b481b3 100644 --- a/lib/views/ranks_averages_view.dart +++ b/lib/views/ranks_averages_view.dart @@ -5,7 +5,7 @@ import 'package:tetra_stats/gen/strings.g.dart'; import 'package:tetra_stats/utils/numers_formats.dart'; import 'package:tetra_stats/views/rank_averages_view.dart'; import 'package:window_manager/window_manager.dart'; -import 'main_view.dart'; // lol +import 'package:tetra_stats/main.dart' show teto; class RankAveragesView extends StatefulWidget { const RankAveragesView({super.key}); diff --git a/lib/views/settings_view.dart b/lib/views/settings_view.dart index fae31a5..07a15d6 100644 --- a/lib/views/settings_view.dart +++ b/lib/views/settings_view.dart @@ -1,20 +1,19 @@ import 'dart:io'; import 'package:go_router/go_router.dart'; import 'package:tetra_stats/data_objects/tetrio.dart'; -import 'package:tetra_stats/main.dart' show packageInfo; +import 'package:tetra_stats/main.dart' show packageInfo, teto, prefs; import 'package:file_selector/file_selector.dart'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:path_provider/path_provider.dart'; -import 'package:shared_preferences/shared_preferences.dart'; import 'package:tetra_stats/gen/strings.g.dart'; import 'package:tetra_stats/services/crud_exceptions.dart'; -import 'package:tetra_stats/services/tetrio_crud.dart'; import 'package:tetra_stats/utils/open_in_browser.dart'; import 'package:window_manager/window_manager.dart'; late String oldWindowTitle; +TextStyle subtitleStyle = const TextStyle(fontFamily: "Eurostile Round Condensed", color: Colors.grey); class SettingsView extends StatefulWidget { const SettingsView({super.key}); @@ -24,10 +23,9 @@ class SettingsView extends StatefulWidget { } class SettingsState extends State { - late SharedPreferences prefs; - final TetrioService teto = TetrioService(); String defaultNickname = "Checking..."; late bool showPositions; + late bool updateInBG; final TextEditingController _playertext = TextEditingController(); @override @@ -46,9 +44,9 @@ class SettingsState extends State { super.dispose(); } - Future _getPreferences() async { - prefs = await SharedPreferences.getInstance(); + void _getPreferences() { showPositions = prefs.getBool("showPositions") ?? false; + updateInBG = prefs.getBool("updateInBG") ?? false; _setDefaultNickname(prefs.getString("player")); } @@ -93,7 +91,7 @@ class SettingsState extends State { children: [ ListTile( title: Text(t.exportDB), - subtitle: Text(t.exportDBDescription, style: const TextStyle(fontFamily: "Eurostile Round Condensed", color: Colors.grey)), + subtitle: Text(t.exportDBDescription, style: subtitleStyle), onTap: () { if (kIsWeb){ ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(t.notForWeb))); @@ -147,7 +145,7 @@ class SettingsState extends State { ), ListTile( title: Text(t.importDB), - subtitle: Text(t.importDBDescription, style: const TextStyle(fontFamily: "Eurostile Round Condensed", color: Colors.grey)), + subtitle: Text(t.importDBDescription, style: subtitleStyle), onTap: () { if (kIsWeb){ ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(t.notForWeb))); @@ -199,6 +197,7 @@ class SettingsState extends State { ), ListTile( title: Text(t.yourID), + subtitle: Text(t.yourIDText, style: subtitleStyle), trailing: Text(defaultNickname), onTap: () => showDialog( context: context, @@ -244,6 +243,7 @@ class SettingsState extends State { ), ListTile( title: Text(t.language), + subtitle: Text("By default, the system language will be selected (if available among Tetra Stats locales, otherwise English)", style: subtitleStyle), trailing: DropdownButton( items: locales, value: LocaleSettings.currentLocale, @@ -261,8 +261,16 @@ class SettingsState extends State { subtitle: Text(t.customizationDescription, style: const TextStyle(fontFamily: "Eurostile Round Condensed", color: Colors.grey)), trailing: const Icon(Icons.arrow_right), onTap: () { - context.go("/customization"); + context.go("/settings/customization"); },), + ListTile(title: Text(t.updateInBackground), + subtitle: Text(t.updateInBackgroundDescription, style: const TextStyle(fontFamily: "Eurostile Round Condensed", color: Colors.grey)), + trailing: Switch(value: updateInBG, onChanged: (bool value){ + prefs.setBool("updateInBG", value); + setState(() { + updateInBG = value; + }); + }),), ListTile(title: Text(t.lbStats), subtitle: Text(t.lbStatsDescription, style: const TextStyle(fontFamily: "Eurostile Round Condensed", color: Colors.grey)), trailing: Switch(value: showPositions, onChanged: (bool value){ diff --git a/lib/views/singleplayer_record_view.dart b/lib/views/singleplayer_record_view.dart new file mode 100644 index 0000000..eb5b2fd --- /dev/null +++ b/lib/views/singleplayer_record_view.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; +import 'package:tetra_stats/data_objects/tetrio.dart'; +import 'package:tetra_stats/gen/strings.g.dart'; +import 'package:tetra_stats/widgets/singleplayer_record.dart'; +import 'package:tetra_stats/widgets/text_timestamp.dart'; + +class SingleplayerRecordView extends StatelessWidget { + final RecordSingle record; + + const SingleplayerRecordView({super.key, required this.record}); + + @override + Widget build(BuildContext context) { + final t = Translations.of(context); + //bool bigScreen = MediaQuery.of(context).size.width >= 368; + return Scaffold( + backgroundColor: Colors.black, + appBar: AppBar( + title: Text("${ + switch (record.endContext.gameType){ + "40l" => t.sprint, + "blitz" => t.blitz, + String() => "5000000 Blast", + } + } ${timestamp(record.timestamp)}"), + ), + body: SafeArea( + child: SingleChildScrollView( + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Column( + children: [ + SingleplayerRecord(record: record, hideTitle: true), + // TODO: Insert replay link here + ] + ) + ], + ) + ) + ), + ); + } + +} \ No newline at end of file diff --git a/lib/views/sprint_and_blitz_averages.dart b/lib/views/sprint_and_blitz_averages.dart index f0f99ec..a37fbd9 100644 --- a/lib/views/sprint_and_blitz_averages.dart +++ b/lib/views/sprint_and_blitz_averages.dart @@ -1,12 +1,11 @@ import 'dart:io'; -import 'dart:ui'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:tetra_stats/data_objects/tetrio.dart'; import 'package:tetra_stats/gen/strings.g.dart'; +import 'package:tetra_stats/utils/relative_timestamps.dart'; import 'package:tetra_stats/utils/text_shadow.dart'; -import 'package:tetra_stats/views/main_view.dart'; import 'package:window_manager/window_manager.dart'; late String oldWindowTitle; @@ -39,6 +38,7 @@ class SprintAndBlitzState extends State { @override Widget build(BuildContext context) { final t = Translations.of(context); + bool bigScreen = MediaQuery.of(context).size.width >= 368; return Scaffold( appBar: AppBar( title: Text(t.sprintAndBlitsViewTitle), @@ -50,6 +50,7 @@ class SprintAndBlitzState extends State { children: [ Container( alignment: Alignment.center, + width: MediaQuery.of(context).size.width, constraints: const BoxConstraints(maxWidth: 600), child: SingleChildScrollView( padding: const EdgeInsets.all(16), @@ -59,18 +60,18 @@ class SprintAndBlitzState extends State { Table( defaultVerticalAlignment: TableCellVerticalAlignment.middle, border: TableBorder.all(color: Colors.grey.shade900), - columnWidths: {0: const FixedColumnWidth(48)}, + columnWidths: const {0: FixedColumnWidth(48)}, children: [ TableRow( children: [ Text(t.rank, textAlign: TextAlign.center, style: const TextStyle(fontFamily: "Eurostile Round", fontSize: 14, fontWeight: FontWeight.w500, color: Colors.white)), Padding( padding: const EdgeInsets.only(right: 8.0), - child: Text(t.sprint, textAlign: TextAlign.right, style: const TextStyle(fontFamily: "Eurostile Round", fontSize: 28, fontWeight: FontWeight.w500, color: Colors.white)), + child: Text(t.sprint, textAlign: TextAlign.right, style: TextStyle(fontFamily: bigScreen ? "Eurostile Round" : "Eurostile Round Condensed", fontSize: 28, fontWeight: FontWeight.w500, color: Colors.white)), ), Padding( padding: const EdgeInsets.only(right: 8.0), - child: Text(t.blitz, textAlign: TextAlign.right, style: const TextStyle(fontFamily: "Eurostile Round", fontSize: 28, fontWeight: FontWeight.w500, color: Colors.white)), + child: Text(t.blitz, textAlign: TextAlign.right, style: TextStyle(fontFamily: bigScreen ? "Eurostile Round" : "Eurostile Round Condensed", fontSize: 28, fontWeight: FontWeight.w500, color: Colors.white)), ), ] ), @@ -80,11 +81,11 @@ class SprintAndBlitzState extends State { Container(decoration: BoxDecoration(boxShadow: [BoxShadow(color: Colors.black.withAlpha(132), blurRadius: 32.0, blurStyle: BlurStyle.inner)]), child: Image.asset("res/tetrio_tl_alpha_ranks/${sprintEntry.key}.png", height: 48)), Padding( padding: const EdgeInsets.only(right: 8.0), - child: Text(get40lTime(sprintEntry.value.inMicroseconds), textAlign: TextAlign.right, style: const TextStyle(fontFamily: "Eurostile Round", fontSize: 28, fontWeight: FontWeight.w500, color: Colors.white, shadows: textShadow)), + child: Text(get40lTime(sprintEntry.value.inMicroseconds), textAlign: TextAlign.right, style: TextStyle(fontFamily: bigScreen ? "Eurostile Round" : "Eurostile Round Condensed", fontSize: 28, fontWeight: FontWeight.w500, color: Colors.white, shadows: textShadow)), ), Padding( padding: const EdgeInsets.only(right: 8.0), - child: Text(NumberFormat.decimalPattern().format(blitzAverages[sprintEntry.key]), textAlign: TextAlign.right, style: const TextStyle(fontFamily: "Eurostile Round", fontSize: 28, fontWeight: FontWeight.w500, color: Colors.white, shadows: textShadow)), + child: Text(NumberFormat.decimalPattern().format(blitzAverages[sprintEntry.key]), textAlign: TextAlign.right, style: TextStyle(fontFamily: bigScreen ? "Eurostile Round" : "Eurostile Round Condensed", fontSize: 28, fontWeight: FontWeight.w500, color: Colors.white, shadows: textShadow)), ), ] ) diff --git a/lib/views/states_view.dart b/lib/views/states_view.dart index 964cb6a..4263129 100644 --- a/lib/views/states_view.dart +++ b/lib/views/states_view.dart @@ -4,8 +4,10 @@ import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:tetra_stats/data_objects/tetrio.dart'; import 'package:tetra_stats/gen/strings.g.dart'; +import 'package:tetra_stats/main.dart' show teto; import 'package:tetra_stats/views/mathes_view.dart'; import 'package:tetra_stats/views/state_view.dart'; +import 'package:tetra_stats/widgets/text_timestamp.dart'; import 'package:window_manager/window_manager.dart'; class StatesView extends StatefulWidget { @@ -37,7 +39,6 @@ class StatesState extends State { @override Widget build(BuildContext context) { final t = Translations.of(context); - final DateFormat dateFormat = DateFormat.yMMMd(LocaleSettings.currentLocale.languageCode).add_Hms(); return Scaffold( appBar: AppBar( title: Text(t.statesViewTitle(number: widget.states.length, nickname: widget.states.last.username.toUpperCase())), @@ -59,14 +60,14 @@ class StatesState extends State { itemCount: widget.states.length, itemBuilder: (context, index) { return ListTile( - title: Text(dateFormat.format(widget.states[index].state)), + title: Text(timestamp(widget.states[index].state)), subtitle: Text(t.statesViewEntry(level: widget.states[index].level.toStringAsFixed(2), gameTime: widget.states[index].gameTime, friends: widget.states[index].friendCount, rd: NumberFormat.compact().format(widget.states[index].tlSeason1.rd))), trailing: IconButton( icon: const Icon(Icons.delete_forever), onPressed: () { DateTime nn = widget.states[index].state; teto.deleteState(widget.states[index]).then((value) => setState(() { - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(t.stateRemoved(date: dateFormat.format(nn))))); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(t.stateRemoved(date: timestamp(nn))))); })); }, ), diff --git a/lib/views/tl_leaderboard_view.dart b/lib/views/tl_leaderboard_view.dart index ab802cc..d69bff8 100644 --- a/lib/views/tl_leaderboard_view.dart +++ b/lib/views/tl_leaderboard_view.dart @@ -174,8 +174,8 @@ class TLLeaderboardState extends State { prototypeItem: ListTile( leading: Text("0", style: TextStyle(fontFamily: "Eurostile Round Extended", fontSize: bigScreen ? 28 : 24, height: 0.9)), title: Text("ehhh...", style: TextStyle(fontFamily: bigScreen ? "Eurostile Round Extended" : "Eurostile Round", height: 0.9)), - trailing: Container(height: bigScreen ? 48 : 36, width: 1,), - subtitle: Text("eh..."), + trailing: SizedBox(height: bigScreen ? 48 : 36, width: 1,), + subtitle: const Text("eh..."), ), itemBuilder: (context, index) { return ListTile( diff --git a/lib/views/tl_match_view.dart b/lib/views/tl_match_view.dart index 9dc15b9..96d23ae 100644 --- a/lib/views/tl_match_view.dart +++ b/lib/views/tl_match_view.dart @@ -1,12 +1,14 @@ -// ignore_for_file: use_build_context_synchronously +// ignore_for_file: use_build_context_synchronously, type_literal_in_constant_pattern import 'dart:io'; import 'package:tetra_stats/data_objects/tetrio_multiplayer_replay.dart'; import 'package:tetra_stats/services/crud_exceptions.dart'; +import 'package:tetra_stats/utils/relative_timestamps.dart'; import 'package:tetra_stats/views/compare_view.dart' show CompareThingy, CompareBoolThingy; import 'package:tetra_stats/widgets/list_tile_trailing_stats.dart'; +import 'package:tetra_stats/widgets/text_timestamp.dart'; import 'package:tetra_stats/widgets/vs_graphs.dart'; -import 'main_view.dart' show teto, secs; +import 'package:tetra_stats/main.dart' show teto; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; @@ -14,11 +16,8 @@ import 'package:tetra_stats/data_objects/tetrio.dart'; import 'package:tetra_stats/gen/strings.g.dart'; import 'package:tetra_stats/utils/open_in_browser.dart'; import 'package:window_manager/window_manager.dart'; -// ignore: avoid_web_libraries_in_flutter -// import 'dart:html' show AnchorElement, document; -final DateFormat dateFormat = DateFormat.yMMMd(LocaleSettings.currentLocale.languageCode).add_Hms(); int roundSelector = -1; // -1 = match averages, otherwise round number-1 List rounds = []; // index zero will be match stats bool timeWeightedStatsAvaliable = true; @@ -49,7 +48,7 @@ class TlMatchResultState extends State { replayData = teto.analyzeReplay(widget.record.replayId, widget.record.replayAvalable); if (!kIsWeb && !Platform.isAndroid && !Platform.isIOS){ windowManager.getTitle().then((value) => oldWindowTitle = value); - windowManager.setTitle("Tetra Stats: ${widget.record.endContext.firstWhere((element) => element.userId == widget.initPlayerId).username.toUpperCase()} ${t.vs} ${widget.record.endContext.firstWhere((element) => element.userId != widget.initPlayerId).username.toUpperCase()} ${t.inTLmatch} ${dateFormat.format(widget.record.timestamp)}"); + windowManager.setTitle("Tetra Stats: ${widget.record.endContext.firstWhere((element) => element.userId == widget.initPlayerId).username.toUpperCase()} ${t.vs} ${widget.record.endContext.firstWhere((element) => element.userId != widget.initPlayerId).username.toUpperCase()} ${t.inTLmatch} ${timestamp(widget.record.timestamp)}"); } super.initState(); } @@ -708,7 +707,7 @@ class TlMatchResultState extends State { final t = Translations.of(context); return Scaffold( appBar: AppBar( - title: Text("${widget.record.endContext.firstWhere((element) => element.userId == widget.initPlayerId).username.toUpperCase()} ${t.vs} ${widget.record.endContext.firstWhere((element) => element.userId != widget.initPlayerId).username.toUpperCase()} ${t.inTLmatch} ${dateFormat.format(widget.record.timestamp)}"), + title: Text("${widget.record.endContext.firstWhere((element) => element.userId == widget.initPlayerId).username.toUpperCase()} ${t.vs} ${widget.record.endContext.firstWhere((element) => element.userId != widget.initPlayerId).username.toUpperCase()} ${t.inTLmatch} ${timestamp(widget.record.timestamp)}"), actions: [ PopupMenuButton( enabled: widget.record.replayAvalable, diff --git a/lib/views/tracked_players_view.dart b/lib/views/tracked_players_view.dart index 2b11976..905e3f4 100644 --- a/lib/views/tracked_players_view.dart +++ b/lib/views/tracked_players_view.dart @@ -4,12 +4,12 @@ import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:tetra_stats/data_objects/tetrio.dart'; import 'package:tetra_stats/gen/strings.g.dart'; -import 'package:tetra_stats/services/tetrio_crud.dart'; +import 'package:tetra_stats/main.dart' show teto; import 'package:tetra_stats/utils/filesizes_converter.dart'; import 'package:tetra_stats/views/states_view.dart'; +import 'package:tetra_stats/widgets/text_timestamp.dart'; import 'package:window_manager/window_manager.dart'; -final TetrioService teto = TetrioService(); late String oldWindowTitle; class TrackedPlayersView extends StatefulWidget { @@ -38,7 +38,6 @@ class TrackedPlayersState extends State { @override Widget build(BuildContext context) { final t = Translations.of(context); - final DateFormat dateFormat = DateFormat.yMMMd(LocaleSettings.currentLocale.languageCode).add_Hms(); return Scaffold( appBar: AppBar( title: Text(t.trackedPlayersViewTitle), @@ -110,7 +109,7 @@ class TrackedPlayersState extends State { itemBuilder: (context, index) { return ListTile( title: Text(t.trackedPlayersEntry(nickname: allPlayers[keys[index]]!.last.username, numberOfStates: allPlayers[keys[index]]!.length)), - subtitle: Text(t.trackedPlayersDescription(firstStateDate: dateFormat.format(allPlayers[keys[index]]!.first.state), lastStateDate: dateFormat.format(allPlayers[keys[index]]!.last.state))), + subtitle: Text(t.trackedPlayersDescription(firstStateDate: timestamp(allPlayers[keys[index]]!.first.state), lastStateDate: timestamp(allPlayers[keys[index]]!.last.state))), trailing: IconButton( icon: const Icon(Icons.delete_forever), onPressed: () { diff --git a/lib/widgets/finesse_thingy.dart b/lib/widgets/finesse_thingy.dart index bb71dc0..937d767 100644 --- a/lib/widgets/finesse_thingy.dart +++ b/lib/widgets/finesse_thingy.dart @@ -1,3 +1,5 @@ +// ignore_for_file: curly_braces_in_flow_control_structures + import 'package:flutter/material.dart'; import 'package:tetra_stats/data_objects/tetrio.dart'; import 'package:tetra_stats/utils/numers_formats.dart'; diff --git a/lib/widgets/gauget_num.dart b/lib/widgets/gauget_num.dart index 2672b75..0f3bd6f 100644 --- a/lib/widgets/gauget_num.dart +++ b/lib/widgets/gauget_num.dart @@ -1,3 +1,5 @@ +// ignore_for_file: curly_braces_in_flow_control_structures + import 'package:flutter/material.dart'; import 'package:syncfusion_flutter_gauges/gauges.dart'; import 'package:tetra_stats/data_objects/tetrio.dart'; diff --git a/lib/widgets/graphs.dart b/lib/widgets/graphs.dart index f8dd910..e8f3ebd 100644 --- a/lib/widgets/graphs.dart +++ b/lib/widgets/graphs.dart @@ -1,10 +1,269 @@ +import 'dart:math'; + import 'package:fl_chart/fl_chart.dart'; +import 'package:fl_chart/src/chart/radar_chart/radar_chart_painter.dart'; +import 'package:fl_chart/src/chart/radar_chart/radar_chart_renderer.dart'; +import 'package:fl_chart/src/chart/base/base_chart/base_chart_painter.dart'; +import 'package:fl_chart/src/utils/canvas_wrapper.dart'; +import 'package:fl_chart/src/utils/utils.dart'; +import 'package:tetra_stats/main.dart' show prefs; import 'package:flutter/material.dart'; import 'package:tetra_stats/data_objects/tetrio.dart'; import 'package:tetra_stats/gen/strings.g.dart'; import 'package:tetra_stats/utils/numers_formats.dart'; +class MyRadarChartPainter extends RadarChartPainter{ + MyRadarChartPainter() : super() { + _backgroundPaint = Paint() + ..style = PaintingStyle.fill + ..isAntiAlias = true; + + _borderPaint = Paint()..style = PaintingStyle.stroke; + + _gridPaint = Paint()..style = PaintingStyle.stroke; + + _tickPaint = Paint()..style = PaintingStyle.stroke; + + _graphPaint = Paint(); + _graphBorderPaint = Paint(); + _graphPointPaint = Paint(); + _ticksTextPaint = TextPainter(); + _titleTextPaint = TextPainter(); + sheetbotRadarGraphs = prefs.getBool("sheetbotRadarGraphs")??false; + } + late Paint _borderPaint; + late Paint _backgroundPaint; + late Paint _gridPaint; + late Paint _tickPaint; + late Paint _graphPaint; + late Paint _graphBorderPaint; + late Paint _graphPointPaint; + + late TextPainter _ticksTextPaint; + late TextPainter _titleTextPaint; + + late bool sheetbotRadarGraphs; + + @override + double getChartCenterValue(RadarChartData data) { + final dataSetMaxValue = sheetbotRadarGraphs ? max(data.maxEntry.value, data.minEntry.value.abs()) : data.maxEntry.value; + final dataSetMinValue = data.minEntry.value; + final tickSpace = getSpaceBetweenTicks(data); + final centerValue = (dataSetMinValue < 0 && sheetbotRadarGraphs) ? 0.0 : dataSetMinValue; + + return dataSetMaxValue == dataSetMinValue + ? getDefaultChartCenterValue() + : centerValue; + } + + @override + double getSpaceBetweenTicks(RadarChartData data) { + final defaultCenterValue = getDefaultChartCenterValue(); + final dataSetMaxValue = sheetbotRadarGraphs ? max(data.maxEntry.value, data.minEntry.value.abs()) : data.maxEntry.value; + final dataSetMinValue = (data.minEntry.value < 0 && sheetbotRadarGraphs) ? 0.0 : data.minEntry.value; + final tickSpace = sheetbotRadarGraphs ? dataSetMaxValue / data.tickCount : (dataSetMaxValue - dataSetMinValue) / data.tickCount; + final defaultTickSpace = + (dataSetMaxValue - defaultCenterValue) / (data.tickCount + 1); + + return dataSetMaxValue == dataSetMinValue ? defaultTickSpace : tickSpace; + } + + @override + double getScaledPoint(RadarEntry point, double radius, RadarChartData data) { + final centerValue = getChartCenterValue(data); + final distanceFromPointToCenter = point.value - centerValue; + final distanceFromMaxToCenter = max(data.maxEntry.value, data.minEntry.value.abs()) - centerValue; + + if (distanceFromMaxToCenter == 0) { + return radius * distanceFromPointToCenter / 0.001; + } + + return radius * distanceFromPointToCenter / distanceFromMaxToCenter; + } + + @override + double getFirstTickValue(RadarChartData data) { + final defaultCenterValue = getDefaultChartCenterValue(); + final dataSetMaxValue = sheetbotRadarGraphs ? max(data.maxEntry.value, data.minEntry.value.abs()) : data.maxEntry.value; + final dataSetMinValue = (data.minEntry.value < 0 && sheetbotRadarGraphs) ? 0.0 : data.minEntry.value; + + return dataSetMaxValue == dataSetMinValue + ? (dataSetMaxValue - defaultCenterValue) / (data.tickCount + 1) + + defaultCenterValue + : dataSetMinValue; + } + + @override + void drawTicks( + BuildContext context, + CanvasWrapper canvasWrapper, + PaintHolder holder, + ) { + final data = holder.data; + final size = canvasWrapper.size; + + final centerX = radarCenterX(size); + final centerY = radarCenterY(size); + final centerOffset = Offset(centerX, centerY); + + /// controls Radar chart size + final radius = radarRadius(size); + + _backgroundPaint.color = data.radarBackgroundColor; + + _borderPaint + ..color = data.radarBorderData.color + ..strokeWidth = data.radarBorderData.width; + + if (data.radarShape == RadarShape.circle) { + /// draw radar background + canvasWrapper + ..drawCircle(centerOffset, radius, _backgroundPaint) + + /// draw radar border + ..drawCircle(centerOffset, radius, _borderPaint); + } else { + final path = + _generatePolygonPath(centerX, centerY, radius, data.titleCount); + + /// draw radar background + canvasWrapper + ..drawPath(path, _backgroundPaint) + + /// draw radar border + ..drawPath(path, _borderPaint); + } + + final tickSpace = getSpaceBetweenTicks(data); + final ticks = []; + var tickValue = getFirstTickValue(data); + + for (var i = 0; i <= data.tickCount; i++) { + ticks.add(tickValue); + tickValue += tickSpace; + } + + final tickDistance = radius / (ticks.length-1); + + _tickPaint + ..color = data.tickBorderData.color + ..strokeWidth = data.tickBorderData.width; + + /// draw radar ticks + ticks.sublist(1, ticks.length).asMap().forEach( + (index, tick) { + final tickRadius = tickDistance * (index + 1); + if (data.radarShape == RadarShape.circle) { + canvasWrapper.drawCircle(centerOffset, tickRadius, _tickPaint); + } else { + canvasWrapper.drawPath( + _generatePolygonPath(centerX, centerY, tickRadius, data.titleCount), + _tickPaint, + ); + } + + _ticksTextPaint + ..text = TextSpan( + text: percentage.format(tick), + style: Utils().getThemeAwareTextStyle(context, data.ticksTextStyle), + ) + ..textDirection = TextDirection.ltr + ..layout(maxWidth: size.width); + canvasWrapper.drawText( + _ticksTextPaint, + Offset(centerX + 5, centerY - tickRadius - _ticksTextPaint.height/2), + ); + }, + ); + } + + Path _generatePolygonPath( + double centerX, + double centerY, + double radius, + int count, + ) { + final path = Path()..moveTo(centerX, centerY - radius); + final angle = (2 * pi) / count; + for (var index = 0; index < count; index++) { + final xAngle = cos(angle * index - pi / 2); + final yAngle = sin(angle * index - pi / 2); + path.lineTo(centerX + radius * xAngle, centerY + radius * yAngle); + } + path.lineTo(centerX, centerY - radius); + return path; + } +} + +class MyRadarChartLeaf extends RadarChartLeaf{ + MyRadarChartLeaf({required super.data, required super.targetData}); + + @override + RenderRadarChart createRenderObject(BuildContext context) => MyRenderRadarChart( + context, + data, + targetData, + MediaQuery.of(context).textScaler, + ); +} + +class MyRenderRadarChart extends RenderRadarChart{ + MyRenderRadarChart(super.context, super.data, super.targetData, super.textScaler); + + @override + RadarChartPainter painter = MyRadarChartPainter(); +} + +class MyRadarChart extends ImplicitlyAnimatedWidget { + const MyRadarChart( + this.data, { + super.key, + Duration swapAnimationDuration = const Duration(milliseconds: 150), + Curve swapAnimationCurve = Curves.linear, + }) : super( + duration: swapAnimationDuration, + curve: swapAnimationCurve, + ); + + /// Determines how the [RadarChart] should be look like. + final RadarChartData data; + + @override + RadarChartState createState() => RadarChartState(); +} + +class RadarChartState extends AnimatedWidgetBaseState { + /// we handle under the hood animations (implicit animations) via this tween, + /// it lerps between the old [RadarChartData] to the new one. + RadarChartDataTween? _radarChartDataTween; + + @override + Widget build(BuildContext context) { + final showingData = _getDate(); + + return MyRadarChartLeaf( + data: _radarChartDataTween!.evaluate(animation), + targetData: showingData, + ); + } + + RadarChartData _getDate() { + return widget.data; + } + + @override + void forEachTween(TweenVisitor visitor) { + _radarChartDataTween = visitor( + _radarChartDataTween, + widget.data, + (dynamic value) => + RadarChartDataTween(begin: value as RadarChartData, end: widget.data), + ) as RadarChartDataTween?; + } +} + class Graphs extends StatelessWidget{ + const Graphs( this.apm, this.pps, @@ -37,7 +296,7 @@ class Graphs extends StatelessWidget{ child: SizedBox( height: 310, width: 310, - child: RadarChart( + child: MyRadarChart( RadarChartData( radarShape: RadarShape.polygon, tickCount: 4, @@ -73,6 +332,8 @@ class Graphs extends StatelessWidget{ }, dataSets: [ RadarDataSet( + fillColor: Theme.of(context).colorScheme.primary.withAlpha(100), + borderColor: Theme.of(context).colorScheme.primary, dataEntries: [ RadarEntry(value: apm * apmWeight), RadarEntry(value: pps * ppsWeight), @@ -114,7 +375,7 @@ class Graphs extends StatelessWidget{ child: SizedBox( height: 310, width: 310, - child: RadarChart( + child: MyRadarChart( RadarChartData( radarShape: RadarShape.polygon, tickCount: 4, @@ -140,6 +401,8 @@ class Graphs extends StatelessWidget{ }, dataSets: [ RadarDataSet( + fillColor: Theme.of(context).colorScheme.primary.withAlpha(100), + borderColor: Theme.of(context).colorScheme.primary, dataEntries: [ RadarEntry(value: playstyle.opener), RadarEntry(value: playstyle.stride), @@ -169,7 +432,7 @@ class Graphs extends StatelessWidget{ child: SizedBox( height: 310, width: 310, - child: RadarChart( + child: MyRadarChart( RadarChartData( radarShape: RadarShape.polygon, tickCount: 4, @@ -195,6 +458,8 @@ class Graphs extends StatelessWidget{ }, dataSets: [ RadarDataSet( + fillColor: Theme.of(context).colorScheme.primary.withAlpha(100), + borderColor: Theme.of(context).colorScheme.primary, dataEntries: [ RadarEntry(value: attack), RadarEntry(value: speed), diff --git a/lib/widgets/list_tile_trailing_stats.dart b/lib/widgets/list_tile_trailing_stats.dart index 5690929..9575506 100644 --- a/lib/widgets/list_tile_trailing_stats.dart +++ b/lib/widgets/list_tile_trailing_stats.dart @@ -13,14 +13,14 @@ class TrailingStats extends StatelessWidget{ @override Widget build(BuildContext context) { - const TextStyle style = TextStyle(height: 1.1, fontWeight: FontWeight.w100); + const TextStyle style = TextStyle(height: 1.1, fontWeight: FontWeight.w100, fontSize: 13); return Table( defaultColumnWidth: const IntrinsicColumnWidth(), defaultVerticalAlignment: TableCellVerticalAlignment.baseline, textBaseline: TextBaseline.alphabetic, columnWidths: const { - 0: FixedColumnWidth(42), - 2: FixedColumnWidth(42), + 0: FixedColumnWidth(48), + 2: FixedColumnWidth(48), }, children: [ TableRow(children: [Text(f2.format(yourAPM), textAlign: TextAlign.right, style: style), const Text(" :", style: style), Text(f2.format(notyourAPM), textAlign: TextAlign.right, style: style), const Text(" APM", textAlign: TextAlign.right, style: style)]), diff --git a/lib/widgets/recent_sp_games.dart b/lib/widgets/recent_sp_games.dart new file mode 100644 index 0000000..9cb033f --- /dev/null +++ b/lib/widgets/recent_sp_games.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:tetra_stats/data_objects/tetrio.dart'; +import 'package:tetra_stats/gen/strings.g.dart'; +import 'package:tetra_stats/utils/relative_timestamps.dart'; +import 'package:tetra_stats/utils/text_shadow.dart'; +import 'package:tetra_stats/views/singleplayer_record_view.dart'; +import 'package:tetra_stats/widgets/sp_trailing_stats.dart'; +import 'package:tetra_stats/widgets/text_timestamp.dart'; + +class RecentSingleplayerGames extends StatelessWidget{ + final SingleplayerStream recent; + final bool hideTitle; + + const RecentSingleplayerGames({required this.recent, this.hideTitle = false, super.key}); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + if (!hideTitle) Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: Text(t.recent, style: const TextStyle(height: 0.1, fontFamily: "Eurostile Round Extended", fontSize: 18)), + ), + for(RecordSingle record in recent.records) ListTile( + onTap: () => Navigator.push(context, MaterialPageRoute(builder: (context) => SingleplayerRecordView(record: record))), + leading: Text( + switch (record.endContext.gameType){ + "40l" => "40L", + "blitz" => "BLZ", + "5mblast" => "5MB", + String() => "huh", + }, + style: const TextStyle(fontFamily: "Eurostile Round", fontSize: 28, shadows: textShadow, height: 0.9) + ), + title: Text( + switch (record.endContext.gameType){ + "40l" => get40lTime(record.endContext.finalTime.inMicroseconds), + "blitz" => t.blitzScore(p: NumberFormat.decimalPattern().format(record.endContext.score)), + "5mblast" => get40lTime(record.endContext.finalTime.inMicroseconds), + String() => "huh", + }, + style: const TextStyle(fontSize: 18)), + subtitle: Text(timestamp(record.timestamp), style: const TextStyle(color: Colors.grey, height: 0.85)), + trailing: SpTrailingStats(record.endContext) + ) + ], + ); + } +} \ No newline at end of file diff --git a/lib/widgets/singleplayer_record.dart b/lib/widgets/singleplayer_record.dart new file mode 100644 index 0000000..3dd9209 --- /dev/null +++ b/lib/widgets/singleplayer_record.dart @@ -0,0 +1,161 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:tetra_stats/data_objects/tetrio.dart'; +import 'package:tetra_stats/gen/strings.g.dart'; +import 'package:tetra_stats/utils/numers_formats.dart'; +import 'package:tetra_stats/utils/open_in_browser.dart'; +import 'package:tetra_stats/utils/relative_timestamps.dart'; +import 'package:tetra_stats/utils/text_shadow.dart'; +import 'package:tetra_stats/views/singleplayer_record_view.dart'; +import 'package:tetra_stats/widgets/finesse_thingy.dart'; +import 'package:tetra_stats/widgets/lineclears_thingy.dart'; +import 'package:tetra_stats/widgets/sp_trailing_stats.dart'; +import 'package:tetra_stats/widgets/stat_sell_num.dart'; +import 'package:tetra_stats/widgets/text_timestamp.dart'; + +class SingleplayerRecord extends StatelessWidget { + final RecordSingle? record; + final SingleplayerStream? stream; + final String? rank; + final bool hideTitle; + + /// Widget that displays data from [record] + const SingleplayerRecord({super.key, required this.record, this.stream, this.rank, this.hideTitle = false}); + + Color getColorOfRank(int rank){ + if (rank == 1) return Colors.yellowAccent; + if (rank == 2) return Colors.blueGrey; + if (rank == 3) return Colors.brown[400]!; + if (rank <= 9) return Colors.blueAccent; + if (rank <= 99) return Colors.greenAccent; + return Colors.grey; + } + + @override + Widget build(BuildContext context) { + if (record == null) return Center(child: Text(t.noRecord, textAlign: TextAlign.center, style: const TextStyle(fontFamily: "Eurostile Round", fontSize: 28))); + late MapEntry closestAverageBlitz; + late bool blitzBetterThanClosestAverage; + bool? blitzBetterThanRankAverage = (rank != null && rank != "z") ? record!.endContext.score > blitzAverages[rank]! : null; + late MapEntry closestAverageSprint; + late bool sprintBetterThanClosestAverage; + bool? sprintBetterThanRankAverage = (rank != null && rank != "z") ? record!.endContext.finalTime < sprintAverages[rank]! : null; + if (record!.endContext.gameType == "40l") { + closestAverageSprint = sprintAverages.entries.singleWhere((element) => element.value == sprintAverages.values.reduce((a, b) => (a-record!.endContext.finalTime).abs() < (b -record!.endContext.finalTime).abs() ? a : b)); + sprintBetterThanClosestAverage = record!.endContext.finalTime < closestAverageSprint.value; + }else if (record!.endContext.gameType == "blitz"){ + closestAverageBlitz = blitzAverages.entries.singleWhere((element) => element.value == blitzAverages.values.reduce((a, b) => (a-record!.endContext.score).abs() < (b -record!.endContext.score).abs() ? a : b)); + blitzBetterThanClosestAverage = record!.endContext.score > closestAverageBlitz.value; + } + + return LayoutBuilder( + builder: (context, constraints) { + bool bigScreen = constraints.maxWidth > 768; + return SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (record!.endContext.gameType == "40l") Padding(padding: const EdgeInsets.only(right: 8.0), + child: Image.asset("res/tetrio_tl_alpha_ranks/${closestAverageSprint.key}.png", height: 96) + ), + if (record!.endContext.gameType == "blitz") Padding(padding: const EdgeInsets.only(right: 8.0), + child: Image.asset("res/tetrio_tl_alpha_ranks/${closestAverageBlitz.key}.png", height: 96) + ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (record!.endContext.gameType == "40l" && !hideTitle) Text(t.sprint, style: const TextStyle(height: 0.1, fontFamily: "Eurostile Round Extended", fontSize: 18)), + if (record!.endContext.gameType == "blitz" && !hideTitle) Text(t.blitz, style: const TextStyle(height: 0.1, fontFamily: "Eurostile Round Extended", fontSize: 18)), + RichText(text: TextSpan( + text: record!.endContext.gameType == "40l" ? get40lTime(record!.endContext.finalTime.inMicroseconds) : NumberFormat.decimalPattern().format(record!.endContext.score), + style: TextStyle(fontFamily: "Eurostile Round Extended", fontSize: bigScreen ? 36 : 32, fontWeight: FontWeight.w500, color: Colors.white), + ), + ), + RichText(text: TextSpan( + text: "", + style: const TextStyle(fontFamily: "Eurostile Round", fontSize: 14, color: Colors.grey), + children: [ + if (record!.endContext.gameType == "40l" && (rank != null && rank != "z")) TextSpan(text: "${t.verdictGeneral(n: readableTimeDifference(record!.endContext.finalTime, sprintAverages[rank]!), verdict: sprintBetterThanRankAverage??false ? t.verdictBetter : t.verdictWorse, rank: rank!.toUpperCase())}\n", style: TextStyle( + color: sprintBetterThanRankAverage??false ? Colors.greenAccent : Colors.redAccent + )) + else if (record!.endContext.gameType == "40l" && (rank == null || rank == "z")) TextSpan(text: "${t.verdictGeneral(n: readableTimeDifference(record!.endContext.finalTime, closestAverageSprint.value), verdict: sprintBetterThanClosestAverage ? t.verdictBetter : t.verdictWorse, rank: closestAverageSprint.key.toUpperCase())}\n", style: TextStyle( + color: sprintBetterThanClosestAverage ? Colors.greenAccent : Colors.redAccent + )) + else if (record!.endContext.gameType == "blitz" && (rank != null && rank != "z")) TextSpan(text: "${t.verdictGeneral(n: readableIntDifference(record!.endContext.score, blitzAverages[rank]!), verdict: blitzBetterThanRankAverage??false ? t.verdictBetter : t.verdictWorse, rank: rank!.toUpperCase())}\n", style: TextStyle( + color: blitzBetterThanRankAverage??false ? Colors.greenAccent : Colors.redAccent + )) + else if (record!.endContext.gameType == "blitz" && (rank == null || rank == "z")) TextSpan(text: "${t.verdictGeneral(n: readableIntDifference(record!.endContext.score, closestAverageBlitz.value), verdict: blitzBetterThanClosestAverage ? t.verdictBetter : t.verdictWorse, rank: closestAverageBlitz.key.toUpperCase())}\n", style: TextStyle( + color: blitzBetterThanClosestAverage ? Colors.greenAccent : Colors.redAccent + )), + if (record!.rank != null) TextSpan(text: "№${record!.rank}", style: TextStyle(color: getColorOfRank(record!.rank!))), + if (record!.rank != null) const TextSpan(text: " • "), + TextSpan(text: timestamp(record!.timestamp)), + ] + ), + ) + ],), + ], + ), + if (record!.endContext.gameType == "40l") Wrap( + alignment: WrapAlignment.spaceBetween, + spacing: 20, + children: [ + StatCellNum(playerStat: record!.endContext.piecesPlaced, playerStatLabel: t.statCellNum.pieces, isScreenBig: bigScreen, higherIsBetter: true, smallDecimal: false), + StatCellNum(playerStat: record!.endContext.pps, playerStatLabel: t.statCellNum.pps, fractionDigits: 2, isScreenBig: bigScreen, higherIsBetter: true, smallDecimal: false), + StatCellNum(playerStat: record!.endContext.kpp, playerStatLabel: t.statCellNum.kpp, fractionDigits: 2, isScreenBig: bigScreen, higherIsBetter: true, smallDecimal: false), + ], + ), + if (record!.endContext.gameType == "blitz") Wrap( + alignment: WrapAlignment.spaceBetween, + crossAxisAlignment: WrapCrossAlignment.start, + spacing: 20, + children: [ + StatCellNum(playerStat: record!.endContext.level, playerStatLabel: t.statCellNum.level, isScreenBig: bigScreen, higherIsBetter: true, smallDecimal: false), + StatCellNum(playerStat: record!.endContext.pps, playerStatLabel: t.statCellNum.pps, fractionDigits: 2, isScreenBig: bigScreen, higherIsBetter: true, smallDecimal: false), + StatCellNum(playerStat: record!.endContext.spp, playerStatLabel: t.statCellNum.spp, fractionDigits: 2, isScreenBig: bigScreen, higherIsBetter: true) + ], + ), + FinesseThingy(record?.endContext.finesse, record?.endContext.finessePercentage), + LineclearsThingy(record!.endContext.clears, record!.endContext.lines, record!.endContext.holds, record!.endContext.tSpins), + if (record!.endContext.gameType == "40l") Text("${record!.endContext.inputs} KP • ${f2.format(record!.endContext.kps)} KPS"), + if (record!.endContext.gameType == "blitz") Text("${record!.endContext.piecesPlaced} P • ${record!.endContext.inputs} KP • ${f2.format(record!.endContext.kpp)} KPP • ${f2.format(record!.endContext.kps)} KPS"), + Wrap( + alignment: WrapAlignment.spaceBetween, + crossAxisAlignment: WrapCrossAlignment.start, + spacing: 20, + children: [ + TextButton(onPressed: (){launchInBrowser(Uri.parse("https://tetr.io/#r:${record!.replayId}"));}, child: Text(t.openSPreplay)), + TextButton(onPressed: (){launchInBrowser(Uri.parse("https://inoue.szy.lol/api/replay/${record!.replayId}"));}, child: Text(t.downloadSPreplay)), + ], + ), + if (stream != null && stream!.records.length > 1) for(int i = 1; i < stream!.records.length; i++) ListTile( + onTap: () => Navigator.push(context, MaterialPageRoute(builder: (context) => SingleplayerRecordView(record: stream!.records[i]))), + leading: Text("#${i+1}", + style: const TextStyle(fontFamily: "Eurostile Round", fontSize: 28, shadows: textShadow, height: 0.9) + ), + title: Text( + switch (stream!.records[i].endContext.gameType){ + "40l" => get40lTime(stream!.records[i].endContext.finalTime.inMicroseconds), + "blitz" => t.blitzScore(p: NumberFormat.decimalPattern().format(stream!.records[i].endContext.score)), + "5mblast" => get40lTime(stream!.records[i].endContext.finalTime.inMicroseconds), + String() => "huh", + }, + style: const TextStyle(fontSize: 18)), + subtitle: Text(timestamp(stream!.records[i].timestamp), style: const TextStyle(color: Colors.grey, height: 0.85)), + trailing: SpTrailingStats(stream!.records[i].endContext) + ) + ] + ), + ), + ); + } + ); + } +} \ No newline at end of file diff --git a/lib/widgets/sp_trailing_stats.dart b/lib/widgets/sp_trailing_stats.dart new file mode 100644 index 0000000..4a5ac72 --- /dev/null +++ b/lib/widgets/sp_trailing_stats.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; +import 'package:tetra_stats/data_objects/tetrio.dart'; +import 'package:tetra_stats/utils/numers_formats.dart'; + +class SpTrailingStats extends StatelessWidget{ + final EndContextSingle endContext; + + const SpTrailingStats(this.endContext, {super.key}); + + @override + Widget build(BuildContext context) { + const TextStyle style = TextStyle(height: 1.1, fontWeight: FontWeight.w100, fontSize: 13); + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text("${endContext.piecesPlaced} P, ${f2.format(endContext.pps)} PPS", style: style, textAlign: TextAlign.right), + Text("${intf.format(endContext.finessePercentage*100)}% F, ${endContext.finesse?.faults} FF", style: style, textAlign: TextAlign.right), + Text(switch(endContext.gameType){ + "40l" => "${f2.format(endContext.kps)} KPS, ${f2.format(endContext.kpp)} KPP", + "blitz" => "${intf.format(endContext.spp)} SPP, lvl ${endContext.level}", + "5mblast" => "${intf.format(endContext.spp)} SPP, ${endContext.lines} L", + String() => "huh" + }, style: style, textAlign: TextAlign.right) + ], + ); + } +} \ No newline at end of file diff --git a/lib/widgets/stat_sell_num.dart b/lib/widgets/stat_sell_num.dart index 4a5ca7c..6e415ed 100644 --- a/lib/widgets/stat_sell_num.dart +++ b/lib/widgets/stat_sell_num.dart @@ -43,16 +43,16 @@ class StatCellNum extends StatelessWidget { @override Widget build(BuildContext context) { + NumberFormat f = NumberFormat.decimalPatternDigits(locale: LocaleSettings.currentLocale.languageCode, decimalDigits: fractionDigits ?? 0); NumberFormat comparef = NumberFormat("+#,###.###;-#,###.###")..maximumFractionDigits = fractionDigits ?? 0; - NumberFormat fractionf = NumberFormat.decimalPatternDigits(locale: LocaleSettings.currentLocale.languageCode, decimalDigits: fractionDigits ?? 0)..maximumIntegerDigits = 0; - num fraction = playerStat.isNegative ? 1 - (playerStat - playerStat.floor()) : playerStat - playerStat.floor(); - int integer = playerStat.isNegative ? (playerStat + fraction).toInt() : (playerStat - fraction).toInt(); + String formated = f.format(playerStat); + List splited = formated.split(f.symbols.DECIMAL_SEP); return Column( children: [ RichText( - text: TextSpan(text: intf.format(integer), + text: TextSpan(text: splited[0], children: [ - TextSpan(text: fractionf.format(fraction).substring(1), style: smallDecimal ? const TextStyle(fontSize: 16) : null) + if ((fractionDigits??0) > 0) TextSpan(text: f.symbols.DECIMAL_SEP+splited[1], style: smallDecimal ? const TextStyle(fontFamily: "Eurostile Round", fontSize: 16) : null) ], style: TextStyle( fontFamily: "Eurostile Round Extended", diff --git a/lib/widgets/text_timestamp.dart b/lib/widgets/text_timestamp.dart new file mode 100644 index 0000000..29c3037 --- /dev/null +++ b/lib/widgets/text_timestamp.dart @@ -0,0 +1,20 @@ +import 'package:intl/intl.dart'; +import 'package:tetra_stats/gen/strings.g.dart'; +import 'package:tetra_stats/main.dart'; +import 'package:tetra_stats/utils/relative_timestamps.dart'; + +final DateFormat dateFormat = DateFormat.yMMMd(LocaleSettings.currentLocale.languageCode).add_Hms(); + +String timestamp(DateTime dateTime){ + int timestampMode = prefs.getInt("timestampMode")??0; + return timestampMode == 2 ? relativeDateTime(dateTime) : dateFormat.format(timestampMode == 1 ? dateTime.toLocal() : dateTime); +} + +// class TextTimestamp extends StatelessWidget{ +// @override +// Widget build(BuildContext context) { +// // TODO: implement build +// return; +// } + +// } \ No newline at end of file diff --git a/lib/widgets/tl_progress_bar.dart b/lib/widgets/tl_progress_bar.dart index 9359629..e1afece 100644 --- a/lib/widgets/tl_progress_bar.dart +++ b/lib/widgets/tl_progress_bar.dart @@ -70,7 +70,7 @@ class TLProgress extends StatelessWidget{ if (tlData.nextAt > 0 && nextRankTRcutoff != null) const TextSpan(text: "\n"), if (nextRankTRcutoff != null) TextSpan(text: "${f2.format(nextRankTRcutoff)} (${comparef2.format(nextRankTRcutoff!-tlData.rating)}) TR"), if ((tlData.nextAt > 0 || nextRankTRcutoff != null) && nextRankGlickoCutoff != null) const TextSpan(text: "\n"), - if (nextRankGlickoCutoff != null) TextSpan(text: (tlData.standing < tlData.nextAt || ((nextRankGlickoCutoff!-tlData.glicko!)/glickoForWin < 0.5 && tlData.percentileRank != "x")) ? t.promotionOnNextWin : t.numOfVictories(wins: f2.format((nextRankGlickoCutoff!-tlData.glicko!)/glickoForWin)), style: TextStyle(color: (tlData.standing < tlData.nextAt || ((nextRankGlickoCutoff!-tlData.glicko!)/glickoForWin < 0.5 && tlData.percentileRank != "x")) ? Colors.greenAccent : null)) + if (nextRankGlickoCutoff != null) TextSpan(text: (tlData.standing < tlData.nextAt || ((nextRankGlickoCutoff!-tlData.glicko!)/glickoForWin < 0.5 && ((tlData.rank != "x" && tlData.rank != "z") || tlData.percentileRank != "x"))) ? t.promotionOnNextWin : t.numOfVictories(wins: f2.format((nextRankGlickoCutoff!-tlData.glicko!)/glickoForWin)), style: TextStyle(color: (tlData.standing < tlData.nextAt || ((nextRankGlickoCutoff!-tlData.glicko!)/glickoForWin < 0.5 && tlData.percentileRank != "x")) ? Colors.greenAccent : null)) ] ) ), @@ -82,8 +82,8 @@ class TLProgress extends StatelessWidget{ maximum: 1, interval: 1, ranges: [ - if (previousRankTRcutoff != null && nextRankTRcutoff != null) LinearGaugeRange(endValue: getBarTR(tlData.rating)!, color: Colors.cyanAccent, position: LinearElementPosition.cross) - else if (tlData.standing != -1) LinearGaugeRange(endValue: getBarPosition(), color: Colors.cyanAccent, position: LinearElementPosition.cross), + if (previousRankTRcutoff != null && nextRankTRcutoff != null) LinearGaugeRange(endValue: getBarTR(tlData.rating)!, color: Theme.of(context).colorScheme.primary, position: LinearElementPosition.cross) + else if (tlData.standing != -1) LinearGaugeRange(endValue: getBarPosition(), color: Theme.of(context).colorScheme.primary, position: LinearElementPosition.cross), if (previousRankTRcutoff != null && previousRankTRcutoffTarget != null) LinearGaugeRange(endValue: getBarTR(previousRankTRcutoffTarget!)!, color: Colors.greenAccent, position: LinearElementPosition.inside), if (nextRankTRcutoff != null && nextRankTRcutoffTarget != null && previousRankTRcutoff != null) LinearGaugeRange(startValue: getBarTR(nextRankTRcutoffTarget!)!, endValue: 1, color: Colors.yellowAccent, position: LinearElementPosition.inside) ], diff --git a/lib/widgets/tl_rating_thingy.dart b/lib/widgets/tl_rating_thingy.dart new file mode 100644 index 0000000..735b06c --- /dev/null +++ b/lib/widgets/tl_rating_thingy.dart @@ -0,0 +1,97 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:tetra_stats/data_objects/tetrio.dart'; +import 'package:tetra_stats/gen/strings.g.dart'; +import 'package:tetra_stats/main.dart' show prefs; +import 'package:tetra_stats/utils/numers_formats.dart'; + +var fDiff = NumberFormat("+#,###.####;-#,###.####"); + +class TLRatingThingy extends StatelessWidget{ + final String userID; + final TetraLeagueAlpha tlData; + final TetraLeagueAlpha? oldTl; + final double? topTR; + + const TLRatingThingy({super.key, required this.userID, required this.tlData, this.oldTl, this.topTR}); + + @override + Widget build(BuildContext context) { + bool oskKagariGimmick = prefs.getBool("oskKagariGimmick")??true; + bool bigScreen = MediaQuery.of(context).size.width >= 768; + String decimalSeparator = f4.symbols.DECIMAL_SEP; + List formatedTR = f4.format(tlData.rating).split(decimalSeparator); + List formatedGlicko = f4.format(tlData.glicko).split(decimalSeparator); + List formatedPercentile = f4.format(tlData.percentile * 100).split(decimalSeparator); + return Wrap( + direction: Axis.horizontal, + alignment: WrapAlignment.spaceAround, + crossAxisAlignment: WrapCrossAlignment.center, + clipBehavior: Clip.hardEdge, + children: [ + (userID == "5e32fc85ab319c2ab1beb07c" && oskKagariGimmick) // he love her so much, you can't even imagine + ? Image.asset("res/icons/kagari.png", height: 128) // Btw why she wearing Kazamatsuri high school uniform? + : Image.asset("res/tetrio_tl_alpha_ranks/${tlData.rank}.png", height: 128), + Column( + children: [ + RichText( + text: TextSpan( + style: const TextStyle(fontFamily: "Eurostile Round", fontSize: 20, color: Colors.white), + children: switch(prefs.getInt("ratingMode")){ + 1 => [ + TextSpan(text: formatedGlicko[0], style: TextStyle(fontFamily: "Eurostile Round Extended", fontSize: bigScreen ? 42 : 28)), + if (formatedGlicko.elementAtOrNull(1) != null) TextSpan(text: decimalSeparator + formatedGlicko[1]), + TextSpan(text: " Glicko", style: TextStyle(fontFamily: "Eurostile Round Extended", fontSize: bigScreen ? 42 : 28)) + ], + 2 => [ + TextSpan(text: "${t.top} ${formatedPercentile[0]}", style: TextStyle(fontFamily: "Eurostile Round Extended", fontSize: bigScreen ? 42 : 28)), + if (formatedPercentile.elementAtOrNull(1) != null) TextSpan(text: decimalSeparator + formatedPercentile[1]), + TextSpan(text: " %", style: TextStyle(fontFamily: "Eurostile Round Extended", fontSize: bigScreen ? 42 : 28)) + ], + _ => [ + TextSpan(text: formatedTR[0], style: TextStyle(fontFamily: "Eurostile Round Extended", fontSize: bigScreen ? 42 : 28)), + if (formatedTR.elementAtOrNull(1) != null) TextSpan(text: decimalSeparator + formatedTR[1]), + TextSpan(text: " TR", style: TextStyle(fontFamily: "Eurostile Round Extended", fontSize: bigScreen ? 42 : 28)) + ], + } + ) + ), + if (oldTl != null) Text( + switch(prefs.getInt("ratingMode")){ + 1 => "${fDiff.format(tlData.glicko! - oldTl!.glicko!)} Glicko", + 2 => "${fDiff.format(tlData.percentile * 100 - oldTl!.percentile * 100)} %", + _ => "${fDiff.format(tlData.rating - oldTl!.rating)} TR" + }, + textAlign: TextAlign.center, + style: TextStyle( + color: tlData.rating - oldTl!.rating < 0 ? + Colors.red : + Colors.green + ), + ), + Column( + children: [ + RichText( + textAlign: TextAlign.center, + softWrap: true, + text: TextSpan( + style: DefaultTextStyle.of(context).style, + children: [ + TextSpan(text: prefs.getInt("ratingMode") == 2 ? "${f2.format(tlData.rating)} TR • % ${t.rank}: ${tlData.percentileRank.toUpperCase()}" : "${t.top} ${f2.format(tlData.percentile * 100)}% (${tlData.percentileRank.toUpperCase()})"), + if (tlData.bestRank != "z") const TextSpan(text: " • "), + if (tlData.bestRank != "z") TextSpan(text: "${t.topRank}: ${tlData.bestRank.toUpperCase()}"), + if (topTR != null) TextSpan(text: " (${f2.format(topTR)} TR)"), + TextSpan(text: " • ${prefs.getInt("ratingMode") == 1 ? "${f2.format(tlData.rating)} TR • RD: " : "Glicko: ${f2.format(tlData.glicko!)}±"}"), + TextSpan(text: f2.format(tlData.rd!), style: tlData.decaying ? TextStyle(color: tlData.rd! > 98 ? Colors.red : Colors.yellow) : null), + if (tlData.decaying) WidgetSpan(child: Icon(Icons.trending_up, color: tlData.rd! > 98 ? Colors.red : Colors.yellow,), alignment: PlaceholderAlignment.middle, baseline: TextBaseline.alphabetic) + ], + ), + ), + ], + ), + ], + ), + ], + ); + } +} \ No newline at end of file diff --git a/lib/widgets/tl_thingy.dart b/lib/widgets/tl_thingy.dart index 6900061..d22333e 100644 --- a/lib/widgets/tl_thingy.dart +++ b/lib/widgets/tl_thingy.dart @@ -3,17 +3,17 @@ import 'package:intl/intl.dart'; import 'package:tetra_stats/data_objects/tetrio.dart'; import 'package:syncfusion_flutter_gauges/gauges.dart'; import 'package:tetra_stats/gen/strings.g.dart'; -import 'package:tetra_stats/main.dart'; import 'package:tetra_stats/utils/colors_functions.dart'; import 'package:tetra_stats/utils/numers_formats.dart'; import 'package:tetra_stats/widgets/gauget_num.dart'; import 'package:tetra_stats/widgets/graphs.dart'; import 'package:tetra_stats/widgets/stat_sell_num.dart'; +import 'package:tetra_stats/widgets/text_timestamp.dart'; import 'package:tetra_stats/widgets/tl_progress_bar.dart'; +import 'package:tetra_stats/widgets/tl_rating_thingy.dart'; -var fDiff = NumberFormat("+#,###.###;-#,###.###"); -var intFDiff = NumberFormat("+#,###;-#,###"); -final DateFormat dateFormat = DateFormat.yMMMd(LocaleSettings.currentLocale.languageCode).add_Hms(); + +var intFDiff = NumberFormat("+#,###.000;-#,###.000"); class TLThingy extends StatefulWidget { final TetraLeagueAlpha tl; @@ -48,7 +48,6 @@ class _TLThingyState extends State { void initState() { _currentRangeValues = const RangeValues(0, 1); sortedStates = widget.states.reversed.toList(); - oskKagariGimmick = prefs.getBool("oskKagariGimmick")??true; oldTl = sortedStates.elementAtOrNull(1)?.tlSeason1; currentTl = widget.tl; super.initState(); @@ -57,8 +56,9 @@ class _TLThingyState extends State { @override Widget build(BuildContext context) { final t = Translations.of(context); - NumberFormat fractionfEstTR = NumberFormat.decimalPatternDigits(locale: LocaleSettings.currentLocale.languageCode, decimalDigits: 2)..maximumIntegerDigits = 0; - NumberFormat fractionfEstTRAcc = NumberFormat.decimalPatternDigits(locale: LocaleSettings.currentLocale.languageCode, decimalDigits: 3)..maximumIntegerDigits = 0; + String decimalSeparator = f2.symbols.DECIMAL_SEP; + List estTRformated = f2.format(currentTl.estTr!.esttr).split(decimalSeparator); + List estTRaccFormated = intFDiff.format(currentTl.esttracc!).split("."); if (currentTl.gamesPlayed == 0) return Center(child: Text(widget.guest ? t.anonTL : widget.bot ? t.botTL : t.neverPlayedTL, style: const TextStyle(fontFamily: "Eurostile Round", fontSize: 28), textAlign: TextAlign.center,)); return LayoutBuilder(builder: (context, constraints) { bool bigScreen = constraints.maxWidth >= 768; @@ -69,7 +69,7 @@ class _TLThingyState extends State { return Column( children: [ if (widget.showTitle) Text(t.tetraLeague, style: TextStyle(fontFamily: "Eurostile Round Extended", fontSize: bigScreen ? 42 : 28)), - if (oldTl != null) Text(t.comparingWith(newDate: dateFormat.format(currentTl.timestamp), oldDate: dateFormat.format(oldTl!.timestamp)), + if (oldTl != null) Text(t.comparingWith(newDate: timestamp(currentTl.timestamp), oldDate: timestamp(oldTl!.timestamp)), textAlign: TextAlign.center,), if (oldTl != null) RangeSlider(values: _currentRangeValues, max: widget.states.length.toDouble(), labels: RangeLabels( @@ -92,52 +92,7 @@ class _TLThingyState extends State { }); }, ), - if (currentTl.gamesPlayed >= 10) - Wrap( - direction: Axis.horizontal, - alignment: WrapAlignment.spaceAround, - crossAxisAlignment: WrapCrossAlignment.center, - clipBehavior: Clip.hardEdge, - children: [ - (widget.userID == "5e32fc85ab319c2ab1beb07c" && oskKagariGimmick) // he love her so much, you can't even imagine - ? Image.asset("res/icons/kagari.png", height: 128) // Btw why she wearing Kazamatsuri high school uniform? - : Image.asset("res/tetrio_tl_alpha_ranks/${currentTl.rank}.png", height: 128), - Column( - children: [ - Text("${f2.format(currentTl.rating)} TR", style: TextStyle(fontFamily: "Eurostile Round Extended", fontSize: bigScreen ? 42 : 28)), - if (oldTl != null) Text( - "${fDiff.format(currentTl.rating - oldTl!.rating)} TR", - textAlign: TextAlign.center, - style: TextStyle( - color: currentTl.rating - oldTl!.rating < 0 ? - Colors.red : - Colors.green - ), - ), - Column( - children: [ - RichText( - textAlign: TextAlign.center, - softWrap: true, - text: TextSpan( - style: DefaultTextStyle.of(context).style, - children: [ - TextSpan(text: "${t.top} ${f2.format(currentTl.percentile * 100)}% (${currentTl.percentileRank.toUpperCase()})"), - if (currentTl.bestRank != "z") const TextSpan(text: " • "), - if (currentTl.bestRank != "z") TextSpan(text: "${t.topRank}: ${currentTl.bestRank.toUpperCase()}"), - if (widget.topTR != null) TextSpan(text: " (${f2.format(widget.topTR)} TR)"), - TextSpan(text: " • Glicko: ${f2.format(currentTl.glicko!)}±"), - TextSpan(text: f2.format(currentTl.rd!), style: currentTl.decaying ? TextStyle(color: currentTl.rd! > 98 ? Colors.red : Colors.yellow) : null), - if (currentTl.decaying) WidgetSpan(child: Icon(Icons.trending_up, color: currentTl.rd! > 98 ? Colors.red : Colors.yellow,), alignment: PlaceholderAlignment.middle, baseline: TextBaseline.alphabetic) - ], - ), - ), - ], - ), - ], - ), - ], - ), + if (currentTl.gamesPlayed >= 10) TLRatingThingy(userID: widget.userID, tlData: currentTl, oldTl: oldTl, topTR: widget.topTR), if (currentTl.gamesPlayed > 9) TLProgress( tlData: currentTl, previousRankTRcutoff: widget.thatRankCutoff, @@ -290,12 +245,10 @@ class _TLThingyState extends State { ), if (currentTl.estTr != null) Padding( - padding: const EdgeInsets.fromLTRB(0, 20, 0, 20), + padding: const EdgeInsets.fromLTRB(8, 20, 8, 20), child: Container( - //alignment: Alignment.center, - width: bigScreen ? MediaQuery.of(context).size.width * 0.4 : MediaQuery.of(context).size.width * 0.85, height: 70, - constraints: const BoxConstraints(maxWidth: 768), + constraints: const BoxConstraints(maxWidth: 500), child: Stack( children: [ Positioned( @@ -306,9 +259,9 @@ class _TLThingyState extends State { Text(t.statCellNum.estOfTR, style: const TextStyle(height: 0.1),), RichText( text: TextSpan( - text: intf.format(currentTl.estTr!.esttr.truncate()), + text: estTRformated[0], style: TextStyle(fontFamily: "Eurostile Round Extended", fontSize: bigScreen ? 36 : 30, fontWeight: FontWeight.w500, color: Colors.white), - children: [TextSpan(text: fractionfEstTR.format(currentTl.estTr!.esttr - currentTl.estTr!.esttr.truncate()).substring(1), style: const TextStyle(fontFamily: "Eurostile Round", fontSize: 14, fontWeight: FontWeight.w100))] + children: [TextSpan(text: decimalSeparator+estTRformated[1], style: const TextStyle(fontFamily: "Eurostile Round", fontSize: 14, fontWeight: FontWeight.w100))] ), ), RichText(text: TextSpan( @@ -335,10 +288,10 @@ class _TLThingyState extends State { Text(t.statCellNum.accOfEst, style: const TextStyle(height: 0.1),), RichText( text: TextSpan( - text: (currentTl.esttracc != null && currentTl.bestRank != "z") ? intFDiff.format(currentTl.esttracc!.truncate()) : "---", + text: (currentTl.esttracc != null && currentTl.bestRank != "z") ? estTRaccFormated[0] : "---", style: TextStyle(fontFamily: "Eurostile Round", fontSize: bigScreen ? 36 : 30, fontWeight: FontWeight.w500, color: Colors.white), children: [ - TextSpan(text: (currentTl.esttracc != null && currentTl.bestRank != "z") ? fractionfEstTRAcc.format(currentTl.esttracc!.isNegative ? 1 - (currentTl.esttracc! - currentTl.esttracc!.truncate()) : (currentTl.esttracc! - currentTl.esttracc!.truncate())).substring(1) : ".---", style: const TextStyle(fontFamily: "Eurostile Round", fontSize: 14, fontWeight: FontWeight.w100)) + TextSpan(text: (currentTl.esttracc != null && currentTl.bestRank != "z") ? decimalSeparator+estTRaccFormated[1] : ".---", style: const TextStyle(fontFamily: "Eurostile Round", fontSize: 14, fontWeight: FontWeight.w100)) ] ), ), diff --git a/lib/widgets/user_thingy.dart b/lib/widgets/user_thingy.dart index c99a53e..f373f3a 100644 --- a/lib/widgets/user_thingy.dart +++ b/lib/widgets/user_thingy.dart @@ -4,11 +4,13 @@ import 'package:flutter/services.dart'; import 'package:syncfusion_flutter_gauges/gauges.dart'; import 'package:tetra_stats/data_objects/tetrio.dart'; import 'package:tetra_stats/gen/strings.g.dart'; +import 'package:tetra_stats/main.dart' show teto; import 'package:tetra_stats/views/compare_view.dart'; import 'package:intl/intl.dart'; import 'package:tetra_stats/utils/text_shadow.dart'; import 'dart:developer' as developer; import 'package:tetra_stats/widgets/stat_sell_num.dart'; +import 'package:tetra_stats/widgets/text_timestamp.dart'; const Map xpTableScuffed = { // level: xp required 05000: 67009018.4885772, @@ -35,7 +37,6 @@ class UserThingy extends StatelessWidget { @override Widget build(BuildContext context) { final t = Translations.of(context); - final DateFormat dateFormat = DateFormat.yMMMd(LocaleSettings.currentLocale.languageCode).add_Hms(); return LayoutBuilder(builder: (context, constraints) { bool bigScreen = constraints.maxWidth > 768; double bannerHeight = bigScreen ? 240 : 120; @@ -125,7 +126,7 @@ class UserThingy extends StatelessWidget { ], ), showStateTimestamp - ? Text(t.fetchDate(date: dateFormat.format(player.state))) + ? Text(t.fetchDate(date: timestamp(player.state))) : Wrap(direction: Axis.horizontal, alignment: WrapAlignment.center, spacing: 25, crossAxisAlignment: WrapCrossAlignment.start, children: [ FutureBuilder( future: teto.isPlayerTracking(player.userId), @@ -339,7 +340,7 @@ class UserThingy extends StatelessWidget { ), children: [ if (player.country != null) TextSpan(text: "${t.countries[player.country]} • "), - TextSpan(text: "${t.playerRole[player.role]}${t.playerRoleAccount}${player.registrationTime == null ? t.wasFromBeginning : '${t.created} ${dateFormat.format(player.registrationTime!)}'}"), + TextSpan(text: "${t.playerRole[player.role]}${t.playerRoleAccount}${player.registrationTime == null ? t.wasFromBeginning : '${t.created} ${timestamp(player.registrationTime!)}'}"), if (player.supporterTier > 0) const TextSpan(text: " • "), if (player.supporterTier > 0) WidgetSpan(child: Icon(player.supporterTier > 1 ? Icons.star : Icons.star_border, color: player.supporterTier > 1 ? Colors.yellowAccent : Colors.white), alignment: PlaceholderAlignment.middle, baseline: TextBaseline.alphabetic), if (player.supporterTier > 0) TextSpan(text: player.supporterTier.toString(), style: TextStyle(color: player.supporterTier > 1 ? Colors.yellowAccent : Colors.white)) @@ -385,7 +386,7 @@ class UserThingy extends StatelessWidget { children: [ Image.asset("res/tetrio_badges/${badge.badgeId}.png"), Text(badge.ts != null - ? t.obtainDate(date: dateFormat.format(badge.ts!)) + ? t.obtainDate(date: timestamp(badge.ts!)) : t.assignedManualy), ], ) diff --git a/lib/widgets/vs_graphs.dart b/lib/widgets/vs_graphs.dart index 2ac5eea..0b78adc 100644 --- a/lib/widgets/vs_graphs.dart +++ b/lib/widgets/vs_graphs.dart @@ -1,5 +1,6 @@ import 'package:fl_chart/fl_chart.dart'; import 'package:flutter/material.dart'; +import 'package:tetra_stats/widgets/graphs.dart' show MyRadarChart; import 'package:tetra_stats/data_objects/tetrio.dart'; import 'package:tetra_stats/gen/strings.g.dart'; @@ -31,7 +32,7 @@ class VsGraphs extends StatelessWidget{ child: SizedBox( height: 310, width: 310, - child: RadarChart( + child: MyRadarChart( RadarChartData( radarShape: RadarShape.polygon, tickCount: 4, @@ -134,7 +135,7 @@ class VsGraphs extends StatelessWidget{ child: SizedBox( height: 310, width: 310, - child: RadarChart( + child: MyRadarChart( RadarChartData( radarShape: RadarShape.polygon, tickCount: 4, @@ -211,7 +212,7 @@ class VsGraphs extends StatelessWidget{ child: SizedBox( height: 310, width: 310, - child: RadarChart( + child: MyRadarChart( RadarChartData( radarShape: RadarShape.polygon, tickCount: 4, diff --git a/pubspec.yaml b/pubspec.yaml index a0bbf67..24111e6 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,7 +2,7 @@ name: tetra_stats description: Track your and other player stats in TETR.IO publish_to: 'none' -version: 1.5.3+19 +version: 1.6.0+20 environment: sdk: '>=3.0.0' diff --git a/res/i18n/strings.i18n.json b/res/i18n/strings.i18n.json index b31172c..3136a87 100644 --- a/res/i18n/strings.i18n.json +++ b/res/i18n/strings.i18n.json @@ -8,6 +8,11 @@ "history": "History", "sprint": "40 Lines", "blitz": "Blitz", + "recent": "Recent", + "recentRuns": "Recent runs", + "blitzScore": "$p points", + "openSPreplay": "Open replay in TETR.IO", + "downloadSPreplay": "Download replay", "other": "Other", "distinguishment": "Distinguishment", "zen": "Zen", @@ -109,14 +114,28 @@ "yourIDAlertTitle": "Your nickname in TETR.IO", "yourIDText": "When app loads, it will retrieve data for this account", "language": "Language", + "updateInBackground": "Update stats in the background", + "updateInBackgroundDescription": "While Tetra Stats is running, it can update stats of the current player when cache expires", "customization": "Customization", - "customizationDescription": "There is only one toggle, planned to add more settings", + "customizationDescription": "Change appearance of different things in Tetra Stats UI", + "oskKagari": "Osk Kagari gimmick", + "oskKagariDescription": "If on, osk's rank on main view will be rendered as :kagari:", + "AccentColor": "Accent color", + "AccentColorDescription": "Almost all interactive UI elements highlighted with this color", + "timestamps": "Timestamps", + "timestampsDescription": "You can choose, in which way timestamps shows time", + "timestampsAbsoluteGMT": "Absolute (GMT)", + "timestampsAbsoluteLocalTime": "Absolute (Your timezone)", + "timestampsRelative": "Relative", + "rating": "Main representation of rating", + "ratingDescription": "TR is not linear, while Glicko does not have boundaries and percentile is volatile", + "ratingLBposition": "LB position", + "sheetbotGraphs": "Sheetbot-like behavior for radar graphs", + "sheetbotGraphsDescription": "If on, points on the graphs can appear on the opposite half of the graph if value is negative", "lbStats": "Show leaderboard based stats", "lbStatsDescription": "That will impact on loading times, but will allow you to see position on LB by stats and comparison with average values", "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", - "oskKagari": "Osk Kagari gimmick", - "oskKagariDescription": "If on, osk's rank on main view will be rendered as :kagari:", "stateViewTitle": "${nickname} account on ${date}", "statesViewTitle": "${number} states of ${nickname} account", "matchesViewTitle": "${nickname} TL matches", diff --git a/res/i18n/strings_ru.i18n.json b/res/i18n/strings_ru.i18n.json index efa247f..00dbc00 100644 --- a/res/i18n/strings_ru.i18n.json +++ b/res/i18n/strings_ru.i18n.json @@ -8,6 +8,11 @@ "history": "История", "sprint": "40 линий", "blitz": "Блиц", + "recent": "Недавно", + "recentRuns": "Недавние", + "blitzScore": "$p очков", + "openSPreplay": "Открыть повтор в TETR.IO", + "downloadSPreplay": "Скачать повтор", "other": "Другое", "distinguishment": "Заслуга", "zen": "Дзен", @@ -109,14 +114,28 @@ "yourIDAlertTitle": "Ваш ник в TETR.IO", "yourIDText": "При запуске приложения оно будет получать статистику этого игрока.", "language": "Язык (Language)", + "updateInBackground": "Обновлять статистику в фоне", + "updateInBackgroundDescription": "Пока Tetra Stats работает, он может обновлять статистику самостоятельно когда кеш истекает", "customization": "Кастомизация", - "customizationDescription": "Здесь только один переключатель, в планах добавить больше", + "customizationDescription": "Измените внешний вид пользовательского интерфейса Tetra Stats", + "oskKagari": "\"Оск Кагари\" прикол", + "oskKagariDescription": "Если включено, вместо настоящего ранга оска будет рендерится :kagari:", + "AccentColor": "Цветовой акцент", + "AccentColorDescription": "Почти все интерактивные элементы пользовательского интерфейса окрашены в этот цвет", + "timestamps": "Метки времени", + "timestampsDescription": "Вы можете выбрать, каким образом метки времени показывают время", + "timestampsAbsoluteGMT": "Абсолютные (GMT)", + "timestampsAbsoluteLocalTime": "Абсолютные (Ваш часовой пояс)", + "timestampsRelative": "Относительные", + "rating": "Основное представление рейтинга", + "ratingDescription": "TR нелинеен, тогда как Glicko не имеет границ, а положение в таблице лидеров волатильно", + "ratingLBposition": "Позиция в рейтинге", + "sheetbotGraphs": "Графики-радары как у sheetBot", + "sheetbotGraphsDescription": "Если включено, точки на графике могут появляться на противоположной стороне графика если значение отрицательное", "lbStats": "Показывать статистику, основанную на рейтинговой таблице", "lbStatsDescription": "Это повлияет на время загрузки, но позволит видеть положение в рейтинге и сравнение со средними значениями по рангу по каждой стате", "aboutApp": "О приложении", "aboutAppText": "${appName} (${packageName}) Версия ${version} Сборка ${buildNumber}\n\nРазработал dan63047\nФормулы предоставил kerrmunism\nИсторию предоставляет p1nkl0bst3r\nВозможность скачивать повторы из TETR.IO предоставляет szy", - "oskKagari": "\"Оск Кагари\" прикол", - "oskKagariDescription": "Если включено, вместо настоящего ранга оска будет рендерится :kagari:", "stateViewTitle": "Аккаунт ${nickname} ${date}", "statesViewTitle": "${number} состояний аккаунта ${nickname}", "matchesViewTitle": "Матчи аккаунта ${nickname}", diff --git a/web/index.html b/web/index.html index 57042b3..745bea0 100644 --- a/web/index.html +++ b/web/index.html @@ -36,21 +36,115 @@ // The value below is injected by flutter build, do not touch. var serviceWorkerVersion = null; + +
+ +
+

Tetra Stats

+

Track your and other player stats in TETR.IO.
Made by dan63047

+

Loading...

+
+