diff --git a/lib/data_objects/tetrio.dart b/lib/data_objects/tetrio.dart index f0220da..3e6d1da 100644 --- a/lib/data_objects/tetrio.dart +++ b/lib/data_objects/tetrio.dart @@ -1037,6 +1037,24 @@ class Distinguishment { } } +class News { + late String id; + late String stream; + 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}); + + News.fromJson(Map json){ + id = json["_id"]; + stream = json["stream"]; + type = json["type"]; + data = json["data"]; + timestamp = DateTime.parse(json['ts']); + } +} + class TetrioPlayersLeaderboard { late String type; late DateTime timestamp; diff --git a/lib/gen/strings.g.dart b/lib/gen/strings.g.dart index b3e157c..d5fbc6c 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: 940 (470 per locale) +/// Strings: 970 (485 per locale) /// -/// Built on 2023-09-23 at 18:57 UTC +/// Built on 2023-10-07 at 16:34 UTC // coverage:ignore-file // ignore_for_file: type=lint @@ -163,6 +163,8 @@ class _StringsEn implements BaseTranslations { String get distinguishment => 'Distinguishment'; String get zen => 'Zen'; String get bio => 'Bio'; + String get news => 'News'; + late final _StringsNewsPartsEn newsParts = _StringsNewsPartsEn._(_root); String get openSearch => 'Search player'; String get closeSearch => 'Close search'; String get refresh => 'Refresh'; @@ -562,6 +564,28 @@ class _StringsEn implements BaseTranslations { }; } +// Path: newsParts +class _StringsNewsPartsEn { + _StringsNewsPartsEn._(this._root); + + final _StringsEn _root; // ignore: unused_field + + // Translations + String get leaderboardStart => 'Got '; + String get leaderboardMiddle => 'on '; + String get personalbest => 'Got a new PB in '; + String get personalbestMiddle => 'of '; + String get badgeStart => 'Obtained a '; + String get badgeEnd => 'badge'; + String get rankupStart => 'Obtained '; + String rankupMiddle({required Object r}) => '${r} rank '; + String get rankupEnd => 'in Tetra League'; + String get tetoSupporter => 'TETR.IO supporter'; + String get supporterStart => 'Become a '; + String get supporterGiftStart => 'Received the gift of '; + String unknownNews({required Object type}) => 'Unknown news of type ${type}'; +} + // Path: statCellNum class _StringsStatCellNumEn { _StringsStatCellNumEn._(this._root); @@ -571,7 +595,8 @@ class _StringsStatCellNumEn { // Translations String get xpLevel => 'XP Level'; String get xpProgress => 'Progress to next level'; - String get xpFrom0To5000 => 'Progress from 0 XP to level 5000'; + String xpFrom0ToLevel({required Object n}) => 'Progress from 0 XP to level ${n}'; + String get xpLeft => 'XP left'; String get hoursPlayed => 'Hours\nPlayed'; String get onlineGames => 'Online\nGames'; String get gamesWon => 'Games\nWon'; @@ -708,6 +733,8 @@ class _StringsRu implements _StringsEn { @override String get distinguishment => 'Заслуга'; @override String get zen => 'Дзен'; @override String get bio => 'Биография'; + @override String get news => 'Новости'; + @override late final _StringsNewsPartsRu newsParts = _StringsNewsPartsRu._(_root); @override String get openSearch => 'Искать игрока'; @override String get closeSearch => 'Закрыть поиск'; @override String get refresh => 'Обновить'; @@ -1107,6 +1134,28 @@ class _StringsRu implements _StringsEn { }; } +// Path: newsParts +class _StringsNewsPartsRu implements _StringsNewsPartsEn { + _StringsNewsPartsRu._(this._root); + + @override final _StringsRu _root; // ignore: unused_field + + // Translations + @override String get leaderboardStart => 'Взял '; + @override String get leaderboardMiddle => 'в таблице '; + @override String get personalbest => 'Поставил новый ЛР в '; + @override String get personalbestMiddle => 'с результатом в '; + @override String get badgeStart => 'Заработал значок '; + @override String get badgeEnd => ''; + @override String get rankupStart => 'Заработал '; + @override String rankupMiddle({required Object r}) => '${r} ранг '; + @override String get rankupEnd => 'в Тетра Лиге'; + @override String get tetoSupporter => 'TETR.IO supporter'; + @override String get supporterStart => 'Стал обладателем '; + @override String get supporterGiftStart => 'Получил подарок в виде '; + @override String unknownNews({required Object type}) => 'Неизвестная новость типа ${type}'; +} + // Path: statCellNum class _StringsStatCellNumRu implements _StringsStatCellNumEn { _StringsStatCellNumRu._(this._root); @@ -1116,7 +1165,8 @@ class _StringsStatCellNumRu implements _StringsStatCellNumEn { // Translations @override String get xpLevel => 'Уровень\nопыта'; @override String get xpProgress => 'Прогресс до следующего уровня'; - @override String get xpFrom0To5000 => 'Прогресс от 0 XP до 5000 уровня'; + @override String xpFrom0ToLevel({required Object n}) => 'Прогресс от 0 XP до ${n} уровня'; + @override String get xpLeft => 'XP осталось'; @override String get hoursPlayed => 'Часов\nСыграно'; @override String get onlineGames => 'Онлайн\nИгр'; @override String get gamesWon => 'Онлайн\nПобед'; @@ -1232,6 +1282,20 @@ extension on _StringsEn { case 'distinguishment': return 'Distinguishment'; case 'zen': return 'Zen'; case 'bio': return 'Bio'; + case 'news': return 'News'; + case 'newsParts.leaderboardStart': return 'Got '; + case 'newsParts.leaderboardMiddle': return 'on '; + case 'newsParts.personalbest': return 'Got a new PB in '; + case 'newsParts.personalbestMiddle': return 'of '; + case 'newsParts.badgeStart': return 'Obtained a '; + case 'newsParts.badgeEnd': return 'badge'; + case 'newsParts.rankupStart': return 'Obtained '; + case 'newsParts.rankupMiddle': return ({required Object r}) => '${r} rank '; + case 'newsParts.rankupEnd': return 'in Tetra League'; + case 'newsParts.tetoSupporter': return 'TETR.IO supporter'; + case 'newsParts.supporterStart': return 'Become a '; + case 'newsParts.supporterGiftStart': return 'Received the gift of '; + case 'newsParts.unknownNews': return ({required Object type}) => 'Unknown news of type ${type}'; case 'openSearch': return 'Search player'; case 'closeSearch': return 'Close search'; case 'refresh': return 'Refresh'; @@ -1356,7 +1420,8 @@ extension on _StringsEn { case 'notForWeb': return 'Function is not available for web version'; case 'statCellNum.xpLevel': return 'XP Level'; case 'statCellNum.xpProgress': return 'Progress to next level'; - case 'statCellNum.xpFrom0To5000': return 'Progress from 0 XP to level 5000'; + case 'statCellNum.xpFrom0ToLevel': return ({required Object n}) => 'Progress from 0 XP to level ${n}'; + case 'statCellNum.xpLeft': return 'XP left'; case 'statCellNum.hoursPlayed': return 'Hours\nPlayed'; case 'statCellNum.onlineGames': return 'Online\nGames'; case 'statCellNum.gamesWon': return 'Games\nWon'; @@ -1712,6 +1777,20 @@ extension on _StringsRu { case 'distinguishment': return 'Заслуга'; case 'zen': return 'Дзен'; case 'bio': return 'Биография'; + case 'news': return 'Новости'; + case 'newsParts.leaderboardStart': return 'Взял '; + case 'newsParts.leaderboardMiddle': return 'в таблице '; + case 'newsParts.personalbest': return 'Поставил новый ЛР в '; + case 'newsParts.personalbestMiddle': return 'с результатом в '; + case 'newsParts.badgeStart': return 'Заработал значок '; + case 'newsParts.badgeEnd': return ''; + case 'newsParts.rankupStart': return 'Заработал '; + case 'newsParts.rankupMiddle': return ({required Object r}) => '${r} ранг '; + case 'newsParts.rankupEnd': return 'в Тетра Лиге'; + case 'newsParts.tetoSupporter': return 'TETR.IO supporter'; + case 'newsParts.supporterStart': return 'Стал обладателем '; + case 'newsParts.supporterGiftStart': return 'Получил подарок в виде '; + case 'newsParts.unknownNews': return ({required Object type}) => 'Неизвестная новость типа ${type}'; case 'openSearch': return 'Искать игрока'; case 'closeSearch': return 'Закрыть поиск'; case 'refresh': return 'Обновить'; @@ -1836,7 +1915,8 @@ extension on _StringsRu { case 'notForWeb': return 'Функция недоступна для веб версии'; case 'statCellNum.xpLevel': return 'Уровень\nопыта'; case 'statCellNum.xpProgress': return 'Прогресс до следующего уровня'; - case 'statCellNum.xpFrom0To5000': return 'Прогресс от 0 XP до 5000 уровня'; + case 'statCellNum.xpFrom0ToLevel': return ({required Object n}) => 'Прогресс от 0 XP до ${n} уровня'; + case 'statCellNum.xpLeft': return 'XP осталось'; case 'statCellNum.hoursPlayed': return 'Часов\nСыграно'; case 'statCellNum.onlineGames': return 'Онлайн\nИгр'; case 'statCellNum.gamesWon': return 'Онлайн\nПобед'; diff --git a/lib/services/tetrio_crud.dart b/lib/services/tetrio_crud.dart index f3ad219..d48d884 100644 --- a/lib/services/tetrio_crud.dart +++ b/lib/services/tetrio_crud.dart @@ -53,8 +53,9 @@ class TetrioService extends DB { final Map _playersCache = {}; final Map> _recordsCache = {}; final Map _leaderboardsCache = {}; + final Map> _newsCache = {}; final Map _tlStreamsCache = {}; // i'm trying to respect oskware api It should look something like {"cached_until": TetrioPlayer} - final client = UserAgentClient("Tetra Stats v1.2.3 (dm @dan63047 if someone abuse that software)", http.Client()); + final client = UserAgentClient("ebany u rot yatogo kazino blyat' (Tetra Stats v1.2.4 dev build)", http.Client()); static final TetrioService _shared = TetrioService._sharedInstance(); factory TetrioService() => _shared; late final StreamController>> _tetrioStreamController; @@ -237,6 +238,63 @@ class TetrioService extends DB { } } + // TODO: Future> getNews(String userID) async + 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"); + } + + Uri url; + if (kIsWeb) { + url = Uri.https('ts.dan63.by', 'oskware_bridge.php', {"endpoint": "tetrioNews", "user": userID.toLowerCase().trim(), "limit": "100"}); + } else { + url = Uri.https('ch.tetr.io', 'api/news/user_${userID.toLowerCase().trim()}', {"limit": "100"}); + } + try { + final response = await client.get(url); + + switch (response.statusCode) { + case 200: + var payload = jsonDecode(response.body); + if (payload['success']) { + List news = [for (var entry in payload['data']['news']) News.fromJson(entry)]; + developer.log("fetchNews: $userID news retrieved and cached", name: "services/tetrio_crud"); + _newsCache[payload['cache']['cached_until'].toString()] = news; + return news; + } else { + developer.log("fetchNews: 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("fetchNews: Failed to fetch stream", 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); + } + } + Future getTLStream(String userID) async { try{ var cached = _tlStreamsCache.entries.firstWhere((element) => element.value.userId == userID); diff --git a/lib/views/main_view.dart b/lib/views/main_view.dart index 8cb1835..d8da28f 100644 --- a/lib/views/main_view.dart +++ b/lib/views/main_view.dart @@ -50,6 +50,14 @@ Future copyToClipboard(String text) async { await Clipboard.setData(ClipboardData(text: text)); } +String get40lTime(int microseconds){ + if (microseconds > 60000000) { + return "${(microseconds/1000000/60).floor()}:${(secs.format(microseconds /1000000 % 60))}"; + } else{ + return timeInSec.format(microseconds / 1000000); + } + } + class _MainState extends State with SingleTickerProviderStateMixin { final bodyGlobalKey = GlobalKey(); bool _searchBoolean = false; @@ -128,7 +136,10 @@ class _MainState extends State with SingleTickerProviderStateMixin { } _searchFor = me.userId; setState((){_titleNickname = me.username;}); - var tlStream = await teto.getTLStream(me.userId); + List requests = await Future.wait([teto.getTLStream(_searchFor), teto.fetchRecords(_searchFor), teto.fetchNews(_searchFor)]); + TetraLeagueAlphaStream tlStream = requests[0] as TetraLeagueAlphaStream; + Map records = requests[1] as Map; + List news = requests[2] as List; List tlMatches = []; bool isTracking = await teto.isPlayerTracking(me.userId); List states = []; @@ -136,7 +147,7 @@ class _MainState extends State with SingleTickerProviderStateMixin { var uniqueTL = {}; if (isTracking){ await teto.storeState(me); - await teto.saveTLMatchesFromStream(await teto.getTLStream(me.userId)); + await teto.saveTLMatchesFromStream(tlStream); tlMatches.addAll(await teto.getTLMatchesbyPlayerID(me.userId)); for (var match in tlStream.records) { if (!tlMatches.contains(match)) tlMatches.add(match); @@ -180,8 +191,7 @@ class _MainState extends State with SingleTickerProviderStateMixin { DropdownMenuItem(value: [for (var tl in uniqueTL) if (tl.estTr != null) FlSpot(tl.timestamp.millisecondsSinceEpoch.toDouble(), tl.estTr!.esttr)], child: Text(t.statCellNum.estOfTR.replaceAll(RegExp(r'\n'), " "))), DropdownMenuItem(value: [for (var tl in uniqueTL) if (tl.esttracc != null) FlSpot(tl.timestamp.millisecondsSinceEpoch.toDouble(), tl.esttracc!)], child: Text(t.statCellNum.accOfEst.replaceAll(RegExp(r'\n'), " "))), ]; - Map records = await teto.fetchRecords(me.userId); - return [me, records, states, tlMatches, compareWith, isTracking]; + return [me, records, states, tlMatches, compareWith, isTracking, news]; } void _justUpdate() { @@ -335,7 +345,9 @@ class _MainState extends State with SingleTickerProviderStateMixin { ? snapshot.data![1]['blitz'][0] : null), _OtherThingy( - zen: snapshot.data![1]['zen'], bio: snapshot.data![0].bio, distinguishment: snapshot.data![0].distinguishment,) + zen: snapshot.data![1]['zen'], bio: snapshot.data![0].bio, + distinguishment: snapshot.data![0].distinguishment, + newsletter: snapshot.data![6],) ], ), ), @@ -454,7 +466,7 @@ class _NavDrawerState extends State { final allPlayers = (snapshot.data != null) ? snapshot.data as Map> : >{}; - List keys = allPlayers.keys.toList(); + List keys = allPlayers.keys.toList().reversed.toList(); // this is so dumb return NestedScrollView( headerSliverBuilder: (context, value) { return [ @@ -669,15 +681,7 @@ class _RecordThingy extends StatelessWidget { fontFamily: "Eurostile Round Extended", fontSize: bigScreen ? 42 : 28)), if (record!.stream.contains("40l")) - if (record!.endContext!.finalTime.inMicroseconds > 60000000) Text( - "${(record!.endContext!.finalTime.inMicroseconds/1000000/60).floor()}:${(secs.format(record!.endContext!.finalTime.inMicroseconds /1000000 % 60))}", - style: TextStyle( - fontFamily: "Eurostile Round Extended", - fontSize: bigScreen ? 42 : 28)) - else Text( - timeInSec.format( - record!.endContext!.finalTime.inMicroseconds / - 1000000), + Text(get40lTime(record!.endContext!.finalTime.inMicroseconds), style: TextStyle( fontFamily: "Eurostile Round Extended", fontSize: bigScreen ? 42 : 28)) @@ -897,7 +901,8 @@ class _OtherThingy extends StatelessWidget { final TetrioZen? zen; final String? bio; final Distinguishment? distinguishment; - const _OtherThingy({Key? key, required this.zen, required this.bio, required this.distinguishment}) + final List? newsletter; + const _OtherThingy({Key? key, required this.zen, required this.bio, required this.distinguishment, this.newsletter}) : super(key: key); List getDistinguishmentSetOfWidgets(String text) { @@ -924,15 +929,115 @@ class _OtherThingy extends StatelessWidget { return result; } + ListTile getNewsTile(News news){ + Map gametypes = { + "40l": t.sprint, + "blitz": t.blitz, + "5mblast": "5,000,000 Blast" + }; + + switch (news.type) { + case "leaderboard": + return ListTile( + title: RichText( + text: TextSpan( + style: const TextStyle(fontFamily: 'Eurostile Round', fontSize: 16), + text: t.newsParts.leaderboardStart, + children: [ + TextSpan(text: "№${news.data["rank"]} ", style: const TextStyle(fontWeight: FontWeight.bold)), + TextSpan(text: t.newsParts.leaderboardMiddle), + TextSpan(text: "№${gametypes[news.data["gametype"]]}", style: const TextStyle(fontWeight: FontWeight.bold)), + ] + ) + ), + subtitle: Text(dateFormat.format(news.timestamp)), + ); + case "personalbest": + return ListTile( + title: RichText( + text: TextSpan( + style: const TextStyle(fontFamily: 'Eurostile Round', fontSize: 16), + text: t.newsParts.personalbest, + children: [ + TextSpan(text: "${gametypes[news.data["gametype"]]} ", style: const TextStyle(fontWeight: FontWeight.bold)), + TextSpan(text: t.newsParts.personalbestMiddle), + TextSpan(text: news.data["gametype"] == "blitz" ? NumberFormat.decimalPattern().format(news.data["result"]) : get40lTime((news.data["result"]*1000).floor()), style: const TextStyle(fontWeight: FontWeight.bold)), + ] + ) + ), + subtitle: Text(dateFormat.format(news.timestamp)), + ); + case "badge": + return ListTile( + title: RichText( + text: TextSpan( + style: const TextStyle(fontFamily: 'Eurostile Round', fontSize: 16), + text: t.newsParts.badgeStart, + children: [ + TextSpan(text: "${news.data["label"]} ", style: const TextStyle(fontWeight: FontWeight.bold)), + TextSpan(text: t.newsParts.badgeEnd) + ] + ) + ), + subtitle: Text(dateFormat.format(news.timestamp)), + ); + case "rankup": + return ListTile( + title: RichText( + text: TextSpan( + style: const TextStyle(fontFamily: 'Eurostile Round', fontSize: 16), + text: t.newsParts.rankupStart, + children: [ + TextSpan(text: t.newsParts.rankupMiddle(r: news.data["rank"].toString().toUpperCase()), style: const TextStyle(fontWeight: FontWeight.bold)), + TextSpan(text: t.newsParts.rankupEnd) + ] + ) + ), + subtitle: Text(dateFormat.format(news.timestamp)), + ); + case "supporter": + return ListTile( + title: RichText( + text: TextSpan( + style: TextStyle(fontFamily: 'Eurostile Round', fontSize: 16), + text: t.newsParts.supporterStart, + children: [ + TextSpan(text: t.newsParts.tetoSupporter, style: TextStyle(fontWeight: FontWeight.bold)) + ] + ) + ), + subtitle: Text(dateFormat.format(news.timestamp)), + ); + case "supporter_gift": + return ListTile( + title: RichText( + text: TextSpan( + style: TextStyle(fontFamily: 'Eurostile Round', fontSize: 16), + text: t.newsParts.supporterGiftStart, + children: [ + TextSpan(text: t.newsParts.tetoSupporter, style: TextStyle(fontWeight: FontWeight.bold)) + ] + ) + ), + subtitle: Text(dateFormat.format(news.timestamp)), + ); + default: + return ListTile( + title: Text(t.newsParts.unknownNews(type: news.type)), + subtitle: Text(dateFormat.format(news.timestamp)), + ); + } + } + @override Widget build(BuildContext context) { return LayoutBuilder(builder: (context, constraints) { bool bigScreen = constraints.maxWidth > 768; return ListView.builder( physics: const AlwaysScrollableScrollPhysics(), - itemCount: 1, + itemCount: newsletter!.length+1, itemBuilder: (BuildContext context, int index) { - return Column( + return index == 0 ? Column( children: [ if (distinguishment != null) Padding( @@ -962,7 +1067,7 @@ class _OtherThingy extends StatelessWidget { ), if (zen != null) Padding( - padding: const EdgeInsets.fromLTRB(0, 0, 0, 0), + padding: const EdgeInsets.fromLTRB(0, 0, 0, 48), child: Column( children: [ Text(t.zen, style: TextStyle(fontFamily: "Eurostile Round Extended", fontSize: bigScreen ? 42 : 28)), @@ -971,9 +1076,10 @@ class _OtherThingy extends StatelessWidget { ], ), ), - + if (newsletter != null && newsletter!.isNotEmpty) + Text(t.news, style: TextStyle(fontFamily: "Eurostile Round Extended", fontSize: bigScreen ? 42 : 28)), ], - ); + ) : getNewsTile(newsletter![index-1]); }, ); }); diff --git a/lib/views/rank_averages_view.dart b/lib/views/rank_averages_view.dart index 6a79b5f..bbcc41f 100644 --- a/lib/views/rank_averages_view.dart +++ b/lib/views/rank_averages_view.dart @@ -377,7 +377,7 @@ class RankState extends State with SingleTickerProviderStateMixin { _ListEntry(value: widget.rank[0].pps, label: t.statCellNum.pps.replaceAll(RegExp(r'\n'), " "), id: "", username: "", approximate: true, fractionDigits: 2), _ListEntry(value: widget.rank[0].vs, label: t.statCellNum.vs.replaceAll(RegExp(r'\n'), " "), id: "", username: "", approximate: true, fractionDigits: 2), _ListEntry(value: widget.rank[1]["avgAPP"], label: t.statCellNum.app.replaceAll(RegExp(r'\n'), " "), id: "", username: "", approximate: true, fractionDigits: 3), - _ListEntry(value: widget.rank[1]["avgAPP"], label: "VS / APM", id: "", username: "", approximate: true, fractionDigits: 3), + _ListEntry(value: widget.rank[1]["avgVSAPM"], label: "VS / APM", id: "", username: "", approximate: true, fractionDigits: 3), _ListEntry(value: widget.rank[1]["avgDSS"], label: t.statCellNum.dss.replaceAll(RegExp(r'\n'), " "), id: "", username: "", approximate: true, fractionDigits: 3), _ListEntry(value: widget.rank[1]["avgDSP"], label: t.statCellNum.dsp.replaceAll(RegExp(r'\n'), " "), id: "", username: "", approximate: true, fractionDigits: 3), _ListEntry(value: widget.rank[1]["avgAPPDSP"], label: t.statCellNum.appdsp.replaceAll(RegExp(r'\n'), " "), id: "", username: "", approximate: true, fractionDigits: 3), diff --git a/lib/widgets/stat_sell_num.dart b/lib/widgets/stat_sell_num.dart index f5659ff..5efc22e 100644 --- a/lib/widgets/stat_sell_num.dart +++ b/lib/widgets/stat_sell_num.dart @@ -29,7 +29,7 @@ class StatCellNum extends StatelessWidget { return Column( children: [ Text( - f.format(playerStat), + fractionDigits == null ? f.format(playerStat.floor()) : f.format(playerStat), style: TextStyle( fontFamily: "Eurostile Round Extended", fontSize: isScreenBig ? 32 : 24, diff --git a/lib/widgets/user_thingy.dart b/lib/widgets/user_thingy.dart index a930d63..4f1e5bc 100644 --- a/lib/widgets/user_thingy.dart +++ b/lib/widgets/user_thingy.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; 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/views/compare_view.dart'; @@ -7,6 +8,17 @@ import 'package:intl/intl.dart'; import 'dart:developer' as developer; import 'package:tetra_stats/widgets/stat_sell_num.dart'; +const Map xpTableScuffed = { // level: xp required + 05000: 67009018.4885772, + 10000: 763653437.386, + 15000: 2337651144.54149, + 20000: 4572735210.50902, + 25000: 7376166347.04745, + 30000: 10693620096.2168, + 40000: 18728882739.482, + 50000: 28468683855.2853 +}; + Future copyToClipboard(String text) async { await Clipboard.setData(ClipboardData(text: text)); } @@ -15,6 +27,7 @@ class UserThingy extends StatelessWidget { final TetrioPlayer player; final bool showStateTimestamp; final Function setState; + const UserThingy({Key? key, required this.player, required this.showStateTimestamp, required this.setState}) : super(key: key); @override @@ -25,212 +38,302 @@ class UserThingy extends StatelessWidget { bool bigScreen = constraints.maxWidth > 768; double bannerHeight = bigScreen ? 240 : 120; double pfpHeight = 128; + int xpTableID = 0; + + while (player.xp > xpTableScuffed.values.toList()[xpTableID]) { + xpTableID++; + } return Column( + mainAxisSize: MainAxisSize.min, children: [ - Flex( - direction: Axis.vertical, - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, + Stack( + alignment: Alignment.topCenter, children: [ - Stack( - alignment: Alignment.topCenter, - children: [ - if (player.bannerRevision != null) - Image.network( - "https://tetr.io/user-content/banners/${player.userId}.jpg?rv=${player.bannerRevision}", - fit: BoxFit.cover, - height: bannerHeight, - errorBuilder: (context, error, stackTrace) { - developer.log("Error with building banner image", name: "main_view", error: error, stackTrace: stackTrace); - return Container(); - }, - ), - Container( - padding: EdgeInsets.fromLTRB(0, player.bannerRevision != null ? bannerHeight / 1.4 : pfpHeight, 0, 0), - child: ClipRRect( - borderRadius: BorderRadius.circular(1000), - child: player.role == "banned" - ? Image.asset( - "res/avatars/tetrio_banned.png", - fit: BoxFit.fitHeight, - height: pfpHeight, - ) - : player.avatarRevision != null - ? Image.network("https://tetr.io/user-content/avatars/${player.userId}.jpg?rv=${player.avatarRevision}", - fit: BoxFit.fitHeight, height: 128, errorBuilder: (context, error, stackTrace) { - developer.log("Error with building profile picture", name: "main_view", error: error, stackTrace: stackTrace); - return Image.asset( - "res/avatars/tetrio_anon.png", - fit: BoxFit.fitHeight, - height: pfpHeight, - ); - }) - : Image.asset( - "res/avatars/tetrio_anon.png", - fit: BoxFit.fitHeight, - height: pfpHeight, - ), - ), + if (player.bannerRevision != null) + Image.network("https://tetr.io/user-content/banners/${player.userId}.jpg?rv=${player.bannerRevision}", + fit: BoxFit.cover, + height: bannerHeight, + errorBuilder: (context, error, stackTrace) { + developer.log("Error with building banner image", name: "main_view", error: error, stackTrace: stackTrace); + return Container(); + }, ), - if (player.verified) - Padding( - padding: EdgeInsets.fromLTRB( - pfpHeight - 22, - bigScreen // verified icon top padding: - ? (player.bannerRevision != null ? bannerHeight + pfpHeight - 96 : pfpHeight + pfpHeight - 32) // for big screen - : (player.bannerRevision != null ? bannerHeight + pfpHeight - 58 : pfpHeight + pfpHeight - 32), // for small screen - 0, - 0), - child: const Icon(Icons.verified), - ) - ], - ), - Flexible( - child: Column( - children: [ - Text(player.username, style: TextStyle(fontFamily: "Eurostile Round Extended", fontSize: bigScreen ? 42 : 28)), - TextButton( - child: Text(player.userId, style: const TextStyle(fontFamily: "Eurostile Round Condensed", fontSize: 14)), - onPressed: () { - copyToClipboard(player.userId); - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(t.copiedToClipboard))); - }), - ], - )), - showStateTimestamp - ? Text(t.fetchDate(date: dateFormat.format(player.state))) - : Wrap(direction: Axis.horizontal, alignment: WrapAlignment.center, spacing: 25, crossAxisAlignment: WrapCrossAlignment.start, children: [ - FutureBuilder( - future: teto.isPlayerTracking(player.userId), - builder: (context, snapshot) { - switch (snapshot.connectionState) { - case ConnectionState.none: - case ConnectionState.waiting: - case ConnectionState.active: - case ConnectionState.done: - if (snapshot.data != null && snapshot.data!) { - return Column( - children: [ - IconButton( - icon: const Icon(Icons.person_remove), - onPressed: () { - teto.deletePlayerToTrack(player.userId).then((value) => setState()); - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(t.stoppedBeingTracked))); - }, + Padding( + padding: EdgeInsets.fromLTRB(8, player.bannerRevision != null ? bannerHeight / 1.4 : 0, 8, bigScreen ? 16 : 0), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Column( + children: [ + Wrap( + direction: bigScreen ? Axis.horizontal : Axis.vertical, + alignment: WrapAlignment.spaceBetween, + spacing: bigScreen ? 25 : 0, + //mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: WrapCrossAlignment.center, + clipBehavior: Clip.hardEdge, + children: [ + Wrap( + direction: bigScreen ? Axis.horizontal : Axis.vertical, + alignment: WrapAlignment.start, + crossAxisAlignment: WrapCrossAlignment.center, + spacing: bigScreen ? 20 : 0, + clipBehavior: Clip.hardEdge, + children: [ + Stack( + alignment: Alignment.topCenter, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(1000), + child: player.role == "banned" + ? Image.asset("res/avatars/tetrio_banned.png", fit: BoxFit.fitHeight, height: pfpHeight,) + : player.avatarRevision != null + ? Image.network("https://tetr.io/user-content/avatars/${player.userId}.jpg?rv=${player.avatarRevision}", + fit: BoxFit.fitHeight, height: 128, errorBuilder: (context, error, stackTrace) { + developer.log("Error with building profile picture", name: "main_view", error: error, stackTrace: stackTrace); + return Image.asset("res/avatars/tetrio_anon.png", fit: BoxFit.fitHeight, height: pfpHeight); + }) + : Image.asset("res/avatars/tetrio_anon.png", fit: BoxFit.fitHeight, height: pfpHeight), ), - Text(t.stopTracking, textAlign: TextAlign.center) - ], - ); - } else { - return Column( - children: [ - IconButton( - icon: const Icon(Icons.person_add), - onPressed: () { - teto.addPlayerToTrack(player).then((value) => setState()); - teto.storeState(player); - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(t.becameTracked))); - }, - ), - Text(t.track, textAlign: TextAlign.center) - ], - ); - } - } - }), - Column( - children: [ - IconButton( - icon: const Icon(Icons.balance), - onPressed: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => CompareView(greenSide: [player, null, player.tlSeason1], redSide: const [null, null, null], greenMode: Mode.player, redMode: Mode.player), + if (player.verified) + Padding( + padding: EdgeInsets.fromLTRB(pfpHeight - 22, pfpHeight - 32, 0, 0), + child: const Icon(Icons.verified), + ) + ], ), - ); - }, - ), - Text(t.compare, textAlign: TextAlign.center) - ], - ) - ]), + Column( + children: [ + Text(player.username, + style: TextStyle( + fontFamily: "Eurostile Round Extended", + fontSize: bigScreen ? 42 : 28, + shadows: const [ + Shadow( + offset: Offset(0.0, 0.0), + blurRadius: 3.0, + color: Colors.black, + ), + Shadow( + offset: Offset(0.0, 0.0), + blurRadius: 8.0, + color: Colors.black, + ), + ], + )), + TextButton( + child: Text(player.userId, style: const TextStyle(fontFamily: "Eurostile Round Condensed", fontSize: 14)), + onPressed: () { + copyToClipboard(player.userId); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(t.copiedToClipboard))); + }), + ], + ), + ], + ), + showStateTimestamp + ? Text(t.fetchDate(date: dateFormat.format(player.state))) + : Wrap(direction: Axis.horizontal, alignment: WrapAlignment.center, spacing: 25, crossAxisAlignment: WrapCrossAlignment.start, children: [ + FutureBuilder( + future: teto.isPlayerTracking(player.userId), + builder: (context, snapshot) { + switch (snapshot.connectionState) { + case ConnectionState.none: + case ConnectionState.waiting: + case ConnectionState.active: + case ConnectionState.done: + if (snapshot.data != null && snapshot.data!) { + return Column( + children: [ + IconButton( + icon: const Icon( + Icons.person_remove, + shadows: [ + Shadow( + offset: Offset(0.0, 0.0), + blurRadius: 3.0, + color: Colors.black, + ), + Shadow( + offset: Offset(0.0, 0.0), + blurRadius: 8.0, + color: Colors.black, + ), + ],), + onPressed: () { + teto.deletePlayerToTrack(player.userId).then((value) => setState()); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(t.stoppedBeingTracked))); + }, + ), + Text(t.stopTracking, textAlign: TextAlign.center) + ], + ); + } else { + return Column( + children: [ + IconButton( + icon: const Icon( + Icons.person_add, + shadows: [ + Shadow( + offset: Offset(0.0, 0.0), + blurRadius: 3.0, + color: Colors.black, + ), + Shadow( + offset: Offset(0.0, 0.0), + blurRadius: 8.0, + color: Colors.black, + ), + ],), + onPressed: () { + teto.addPlayerToTrack(player).then((value) => setState()); + teto.storeState(player); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(t.becameTracked))); + }, + ), + Text(t.track, textAlign: TextAlign.center) + ], + ); + } + } + }), + Column( + children: [ + IconButton( + icon: const Icon( + Icons.balance, + shadows: [ + Shadow( + offset: Offset(0.0, 0.0), + blurRadius: 3.0, + color: Colors.black, + ), + Shadow( + offset: Offset(0.0, 0.0), + blurRadius: 8.0, + color: Colors.black, + ), + ],), + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => CompareView(greenSide: [player, null, player.tlSeason1], redSide: const [null, null, null], greenMode: Mode.player, redMode: Mode.player), + ), + ); + }, + ), + Text(t.compare, textAlign: TextAlign.center) + ], + ) + ]) + ]), + ], + ), + ], + ), + ), ], ), if (!["banned", "p1nkl0bst3r"].contains(player.role)) - Wrap( - direction: Axis.horizontal, - alignment: WrapAlignment.center, - spacing: 25, - crossAxisAlignment: WrapCrossAlignment.start, - clipBehavior: Clip.hardEdge, // hard WHAT??? - children: [ - StatCellNum( - playerStat: player.level, - playerStatLabel: t.statCellNum.xpLevel, - isScreenBig: bigScreen, - alertWidgets: [Text("${NumberFormat.decimalPatternDigits(locale: LocaleSettings.currentLocale.languageCode, decimalDigits: 2).format(player.xp)} XP", style: const TextStyle(fontFamily: "Eurostile Round Extended"),), Text("${t.statCellNum.xpProgress}: ${((player.level - player.level.floor()) * 100).toStringAsFixed(2)} %"), Text("${t.statCellNum.xpFrom0To5000}: ${((player.xp / 67009017.7589378) * 100).toStringAsFixed(2)} %")], - okText: t.popupActions.ok, - higherIsBetter: true, + Wrap( + // mainAxisSize: MainAxisSize.min, + direction: Axis.horizontal, + alignment: WrapAlignment.center, + spacing: 25, + crossAxisAlignment: WrapCrossAlignment.start, + clipBehavior: Clip.hardEdge, // hard WHAT??? + children: [ + StatCellNum( + playerStat: player.level, + playerStatLabel: t.statCellNum.xpLevel, + isScreenBig: bigScreen, + alertWidgets: [ + Text( + "${NumberFormat.decimalPatternDigits(locale: LocaleSettings.currentLocale.languageCode, decimalDigits: 2).format(player.xp)} XP", + style: const TextStyle(fontFamily: "Eurostile Round", fontWeight: FontWeight.bold) + ), + Padding( + padding: const EdgeInsets.fromLTRB(0, 8, 0, 8), + child: SfLinearGauge( + minimum: 0, + maximum: 1, + interval: 1, + ranges: [ + LinearGaugeRange(startValue: 0, endValue: player.level - player.level.floor(), color: Colors.cyanAccent), + LinearGaugeRange(startValue: 0, endValue: (player.xp / xpTableScuffed.values.toList()[xpTableID]), color: Colors.redAccent, position: LinearElementPosition.cross) + ], + // markerPointers: [LinearShapePointer(value: player.level - player.level.floor(), position: LinearElementPosition.inside, shapeType: LinearShapePointerType.triangle, color: Colors.white, height: 20)], + showTicks: true, + showLabels: false + ), ), - if (player.gameTime >= Duration.zero) - StatCellNum( - playerStat: player.gameTime.inHours, - playerStatLabel: t.statCellNum.hoursPlayed, - isScreenBig: bigScreen, - alertWidgets: [Text("${t.exactGametime}: ${player.gameTime.toString()}")], - higherIsBetter: true,), - if (player.gamesPlayed >= 0) - StatCellNum( - playerStat: player.gamesPlayed, - isScreenBig: bigScreen, - playerStatLabel: t.statCellNum.onlineGames, - higherIsBetter: true,), - if (player.gamesWon >= 0) - StatCellNum( - playerStat: player.gamesWon, - isScreenBig: bigScreen, - playerStatLabel: t.statCellNum.gamesWon, - higherIsBetter: true,), - if (player.friendCount > 0) - StatCellNum( - playerStat: player.friendCount, - isScreenBig: bigScreen, - playerStatLabel: t.statCellNum.friends, - higherIsBetter: true,), - ], + Text("${t.statCellNum.xpProgress}: ${((player.level - player.level.floor()) * 100).toStringAsFixed(2)} %"), + Text("${t.statCellNum.xpFrom0ToLevel(n: xpTableScuffed.keys.toList()[xpTableID])}: ${((player.xp / xpTableScuffed.values.toList()[xpTableID]) * 100).toStringAsFixed(2)} % (${NumberFormat.decimalPatternDigits(locale: LocaleSettings.currentLocale.languageCode, decimalDigits: 0).format(xpTableScuffed.values.toList()[xpTableID] - player.xp)} ${t.statCellNum.xpLeft})")], + okText: t.popupActions.ok, + higherIsBetter: true, ), - if (player.role == "banned") Text( - t.bigRedBanned, - textAlign: TextAlign.center, - style: TextStyle( - fontFamily: "Eurostile Round Extended", - fontWeight: FontWeight.w900, - color: Colors.red, - fontSize: bigScreen ? 60 : 45, - ), - ), - if (player.role == "p1nkl0bst3r") Text( - t.p1nkl0bst3rAlert, + if (player.gameTime >= Duration.zero) + StatCellNum( + playerStat: player.gameTime.inHours, + playerStatLabel: t.statCellNum.hoursPlayed, + isScreenBig: bigScreen, + alertWidgets: [Text("${t.exactGametime}: ${player.gameTime.toString()}")], + higherIsBetter: true,), + if (player.gamesPlayed >= 0) + StatCellNum( + playerStat: player.gamesPlayed, + isScreenBig: bigScreen, + playerStatLabel: t.statCellNum.onlineGames, + higherIsBetter: true,), + if (player.gamesWon >= 0) + StatCellNum( + playerStat: player.gamesWon, + isScreenBig: bigScreen, + playerStatLabel: t.statCellNum.gamesWon, + higherIsBetter: true,), + if (player.friendCount > 0) + StatCellNum( + playerStat: player.friendCount, + isScreenBig: bigScreen, + playerStatLabel: t.statCellNum.friends, + higherIsBetter: true,), + ], + ), + if (player.role == "banned") Text( + t.bigRedBanned, textAlign: TextAlign.center, - style: const TextStyle( - fontFamily: "Eurostile Round", - fontSize: 16, - ) + style: TextStyle( + fontFamily: "Eurostile Round Extended", + fontWeight: FontWeight.w900, + color: Colors.red, + fontSize: bigScreen ? 60 : 45, + ), ), - if (player.badstanding != null && player.badstanding!) - Text( - t.bigRedBadStanding, + if (player.role == "p1nkl0bst3r") Text( + t.p1nkl0bst3rAlert, textAlign: TextAlign.center, - style: TextStyle( - fontFamily: "Eurostile Round Extended", - fontWeight: FontWeight.w900, - color: Colors.red, - fontSize: bigScreen ? 60 : 45, - ), + style: const TextStyle( + fontFamily: "Eurostile Round", + fontSize: 16, + ) ), - if (player.role != "p1nkl0bst3r") Row( + if (player.badstanding != null && player.badstanding!) + Text( + t.bigRedBadStanding, + textAlign: TextAlign.center, + style: TextStyle( + fontFamily: "Eurostile Round Extended", + fontWeight: FontWeight.w900, + color: Colors.red, + fontSize: bigScreen ? 60 : 45, + ), + ), + if (player.role != "p1nkl0bst3r") Padding( + padding: EdgeInsets.only(top: bigScreen ? 8 : 0), + child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Expanded( @@ -244,6 +347,7 @@ class UserThingy extends StatelessWidget { ) ], ), + ), Wrap( direction: Axis.horizontal, alignment: WrapAlignment.center, diff --git a/res/i18n/strings.i18n.json b/res/i18n/strings.i18n.json index 1768f00..adf56e7 100644 --- a/res/i18n/strings.i18n.json +++ b/res/i18n/strings.i18n.json @@ -12,6 +12,22 @@ "distinguishment": "Distinguishment", "zen": "Zen", "bio": "Bio", + "news": "News", + "newsParts":{ + "leaderboardStart": "Got ", + "leaderboardMiddle": "on ", + "personalbest": "Got a new PB in ", + "personalbestMiddle": "of ", + "badgeStart": "Obtained a ", + "badgeEnd": "badge", + "rankupStart": "Obtained ", + "rankupMiddle": "${r} rank ", + "rankupEnd": "in Tetra League", + "tetoSupporter": "TETR.IO supporter", + "supporterStart": "Become a ", + "supporterGiftStart": "Received the gift of ", + "unknownNews": "Unknown news of type ${type}" + }, "openSearch": "Search player", "closeSearch": "Close search", "refresh": "Refresh", @@ -137,7 +153,8 @@ "statCellNum":{ "xpLevel": "XP Level", "xpProgress": "Progress to next level", - "xpFrom0To5000": "Progress from 0 XP to level 5000", + "xpFrom0ToLevel": "Progress from 0 XP to level $n", + "xpLeft": "XP left", "hoursPlayed": "Hours\nPlayed", "onlineGames": "Online\nGames", "gamesWon": "Games\nWon", diff --git a/res/i18n/strings_ru.i18n.json b/res/i18n/strings_ru.i18n.json index c4b4f2a..d83fc23 100644 --- a/res/i18n/strings_ru.i18n.json +++ b/res/i18n/strings_ru.i18n.json @@ -12,6 +12,22 @@ "distinguishment": "Заслуга", "zen": "Дзен", "bio": "Биография", + "news": "Новости", + "newsParts":{ + "leaderboardStart": "Взял ", + "leaderboardMiddle": "в таблице ", + "personalbest": "Поставил новый ЛР в ", + "personalbestMiddle": "с результатом в ", + "badgeStart": "Заработал значок ", + "badgeEnd": "", + "rankupStart": "Заработал ", + "rankupMiddle": "${r} ранг ", + "rankupEnd": "в Тетра Лиге", + "tetoSupporter": "TETR.IO supporter", + "supporterStart": "Стал обладателем ", + "supporterGiftStart": "Получил подарок в виде ", + "unknownNews": "Неизвестная новость типа ${type}" + }, "openSearch": "Искать игрока", "closeSearch": "Закрыть поиск", "refresh": "Обновить", @@ -137,7 +153,8 @@ "statCellNum": { "xpLevel": "Уровень\nопыта", "xpProgress": "Прогресс до следующего уровня", - "xpFrom0To5000": "Прогресс от 0 XP до 5000 уровня", + "xpFrom0ToLevel": "Прогресс от 0 XP до $n уровня", + "xpLeft": "XP осталось", "hoursPlayed": "Часов\nСыграно", "onlineGames": "Онлайн\nИгр", "gamesWon": "Онлайн\nПобед",