Now im actually able to collect data about players

Also thinking about reusing my widgets,
and i illegally downloaded some badges,
yeah and also it's possible to watch previous states of the account
This commit is contained in:
dan63047 2023-06-11 00:56:14 +03:00
parent a3b056953a
commit 5bb811019a
29 changed files with 877 additions and 616 deletions

View File

@ -1,4 +1,5 @@
import 'dart:math'; import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:vector_math/vector_math.dart'; import 'package:vector_math/vector_math.dart';
import 'dart:developer' as developer; import 'dart:developer' as developer;
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
@ -151,8 +152,8 @@ class TetrioPlayer {
if (userId != other.userId) return false; if (userId != other.userId) return false;
if (username != other.username) return false; if (username != other.username) return false;
if (role != other.role) return false; if (role != other.role) return false;
if (badges != other.badges) return false; if (listEquals(badges, other.badges) == false) return false;
if (bio != other.bio) return false; //if (bio != other.bio) return false;
if (country != other.country) return false; if (country != other.country) return false;
if (friendCount != other.friendCount) return false; if (friendCount != other.friendCount) return false;
if (gamesPlayed != other.gamesPlayed) return false; if (gamesPlayed != other.gamesPlayed) return false;
@ -222,6 +223,8 @@ class Connections {
Connections.fromJson(Map<String, dynamic> json) { Connections.fromJson(Map<String, dynamic> json) {
discord = json['discord'] != null ? Discord.fromJson(json['discord']) : null; discord = json['discord'] != null ? Discord.fromJson(json['discord']) : null;
} }
@override
bool operator ==(covariant Connections other) => discord == other.discord;
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{}; final Map<String, dynamic> data = <String, dynamic>{};
@ -308,6 +311,9 @@ class Discord {
username = json['username']; username = json['username'];
} }
@override
bool operator ==(covariant Discord other) => id == other.id;
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{}; final Map<String, dynamic> data = <String, dynamic>{};
data['id'] = id; data['id'] = id;
@ -672,6 +678,9 @@ class TetraLeagueAlpha {
(nerdStats != null) ? Playstyle(apm!, pps!, nerdStats!.app, nerdStats!.vsapm, nerdStats!.dsp, nerdStats!.gbe, estTr!.srarea, estTr!.statrank) : null; (nerdStats != null) ? Playstyle(apm!, pps!, nerdStats!.app, nerdStats!.vsapm, nerdStats!.dsp, nerdStats!.gbe, estTr!.srarea, estTr!.statrank) : null;
} }
@override
bool operator ==(covariant TetraLeagueAlpha other) => gamesPlayed == other.gamesPlayed && rd == other.rd;
double? get esttracc => (estTr != null) ? estTr!.esttr - rating : null; double? get esttracc => (estTr != null) ? estTr!.esttr - rating : null;
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
@ -769,6 +778,9 @@ class Distinguishment {
footer = json['footer']; footer = json['footer'];
} }
@override
bool operator ==(covariant Distinguishment other) => type == other.type && detail == other.detail && header == other.header && footer == other.footer;
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{}; final Map<String, dynamic> data = <String, dynamic>{};
data['type'] = type; data['type'] = type;

View File

@ -14,7 +14,7 @@ void main() {
routes: { routes: {
"/settings": (context) => const SettingsView(), "/settings": (context) => const SettingsView(),
"/compare": (context) => const CompareView(), "/compare": (context) => const CompareView(),
"/states": (context) => const StatesView(), "/states": (context) => const TrackedPlayersView(),
"/calc": (context) => const CalcView() "/calc": (context) => const CalcView()
}, },
theme: ThemeData( theme: ThemeData(

View File

@ -64,6 +64,12 @@ class TetrioService extends DB {
} }
} }
Future<String> getNicknameByID(String id) async {
if (id.length <= 16) return id;
TetrioPlayer player = await getPlayer(id).then((value) => value.last);
return player.username;
}
Future<void> createPlayer(TetrioPlayer tetrioPlayer) async { Future<void> createPlayer(TetrioPlayer tetrioPlayer) async {
ensureDbIsOpen(); ensureDbIsOpen();
final db = getDatabaseOrThrow(); final db = getDatabaseOrThrow();
@ -89,6 +95,17 @@ class TetrioService extends DB {
db.insert(tetrioUsersToTrackTable, {idCol: tetrioPlayer.userId}); db.insert(tetrioUsersToTrackTable, {idCol: tetrioPlayer.userId});
} }
Future<bool> isPlayerTracking(String id) async {
ensureDbIsOpen();
final db = getDatabaseOrThrow();
final results = await db.query(tetrioUsersToTrackTable, where: '$idCol = ?', whereArgs: [id.toLowerCase()]);
if (results.isEmpty) {
return false;
} else {
return true;
}
}
Future<Iterable<String>> getAllPlayerToTrack() async { Future<Iterable<String>> getAllPlayerToTrack() async {
await ensureDbIsOpen(); await ensureDbIsOpen();
final db = getDatabaseOrThrow(); final db = getDatabaseOrThrow();
@ -119,7 +136,8 @@ class TetrioService extends DB {
await createPlayer(tetrioPlayer); await createPlayer(tetrioPlayer);
states = await getPlayer(tetrioPlayer.userId); states = await getPlayer(tetrioPlayer.userId);
} }
if (!_players[tetrioPlayer.userId]!.last.isSameState(tetrioPlayer)) states.add(tetrioPlayer); bool test = _players[tetrioPlayer.userId]!.last.isSameState(tetrioPlayer);
if (test == false) states.add(tetrioPlayer);
final Map<String, dynamic> statesJson = {}; final Map<String, dynamic> statesJson = {};
for (var e in states) { for (var e in states) {
statesJson.addEntries({e.state.millisecondsSinceEpoch.toString(): e.toJson()}.entries); statesJson.addEntries({e.state.millisecondsSinceEpoch.toString(): e.toJson()}.entries);

View File

@ -1,19 +1,13 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'dart:developer' as developer; import 'dart:developer' as developer;
import 'dart:convert';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:tetra_stats/data_objects/tetrio.dart'; import 'package:tetra_stats/data_objects/tetrio.dart';
import 'package:tetra_stats/services/tetrio_crud.dart'; import 'package:tetra_stats/services/tetrio_crud.dart';
import 'package:tetra_stats/services/sqlite_db_controller.dart'; import 'package:tetra_stats/services/crud_exceptions.dart';
import 'package:fl_chart/fl_chart.dart'; import 'package:tetra_stats/widgets/stat_sell_num.dart';
import 'package:tetra_stats/widgets/tl_thingy.dart';
extension StringExtension on String { import 'package:tetra_stats/widgets/user_thingy.dart';
String capitalize() {
return "${this[0].toUpperCase()}${substring(1).toLowerCase()}";
}
}
String _searchFor = "dan63047"; String _searchFor = "dan63047";
Future<TetrioPlayer>? me; Future<TetrioPlayer>? me;
@ -103,6 +97,10 @@ class _MainState extends State<MainView> with SingleTickerProviderStateMixin {
}); });
} }
void _justUpdate() {
setState(() {});
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
@ -150,13 +148,13 @@ class _MainState extends State<MainView> with SingleTickerProviderStateMixin {
), ),
PopupMenuButton( PopupMenuButton(
itemBuilder: (BuildContext context) => <PopupMenuEntry>[ itemBuilder: (BuildContext context) => <PopupMenuEntry>[
const PopupMenuItem( // const PopupMenuItem(
value: "/compare", // value: "/compare",
child: Text('Compare'), // child: Text('Compare'),
), // ),
const PopupMenuItem( const PopupMenuItem(
value: "/states", value: "/states",
child: Text('Players you track'), child: Text('Show stored data'),
), ),
const PopupMenuItem( const PopupMenuItem(
value: "/calc", value: "/calc",
@ -192,11 +190,20 @@ class _MainState extends State<MainView> with SingleTickerProviderStateMixin {
case ConnectionState.done: case ConnectionState.done:
bool bigScreen = MediaQuery.of(context).size.width > 1024; bool bigScreen = MediaQuery.of(context).size.width > 1024;
if (snapshot.hasData) { if (snapshot.hasData) {
if (_searchFor.length > 16) _searchFor = snapshot.data!.username;
teto.isPlayerTracking(snapshot.data!.userId).then((value) {
if (value) teto.storeState(snapshot.data!);
});
return NestedScrollView( return NestedScrollView(
controller: _scrollController, controller: _scrollController,
headerSliverBuilder: (context, value) { headerSliverBuilder: (context, value) {
return [ return [
SliverToBoxAdapter(child: _UserThingy(player: snapshot.data!)), SliverToBoxAdapter(
child: UserThingy(
player: snapshot.data!,
showStateTimestamp: false,
setState: _justUpdate,
)),
SliverToBoxAdapter( SliverToBoxAdapter(
child: TabBar( child: TabBar(
controller: _tabController, controller: _tabController,
@ -214,7 +221,7 @@ class _MainState extends State<MainView> with SingleTickerProviderStateMixin {
body: TabBarView( body: TabBarView(
controller: _tabController, controller: _tabController,
children: [ children: [
_TLThingy(tl: snapshot.data!.tlSeason1, userID: snapshot.data!.userId), TLThingy(tl: snapshot.data!.tlSeason1, userID: snapshot.data!.userId),
_RecordThingy(record: (snapshot.data!.sprint.isNotEmpty) ? snapshot.data!.sprint[0] : null), _RecordThingy(record: (snapshot.data!.sprint.isNotEmpty) ? snapshot.data!.sprint[0] : null),
_RecordThingy(record: (snapshot.data!.blitz.isNotEmpty) ? snapshot.data!.blitz[0] : null), _RecordThingy(record: (snapshot.data!.blitz.isNotEmpty) ? snapshot.data!.blitz[0] : null),
_OtherThingy(zen: snapshot.data!.zen, bio: snapshot.data!.bio) _OtherThingy(zen: snapshot.data!.zen, bio: snapshot.data!.bio)
@ -252,9 +259,11 @@ class NavDrawer extends StatefulWidget {
class _NavDrawerState extends State<NavDrawer> { class _NavDrawerState extends State<NavDrawer> {
late ScrollController _scrollController; late ScrollController _scrollController;
String homePlayerNickname = "Checking...";
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_setHomePlayerNickname(prefs.getString("player"));
_scrollController = ScrollController(); _scrollController = ScrollController();
} }
@ -264,6 +273,19 @@ class _NavDrawerState extends State<NavDrawer> {
super.dispose(); super.dispose();
} }
Future<void> _setHomePlayerNickname(String? n) async {
if (n != null) {
try {
homePlayerNickname = await teto.getNicknameByID(n);
} on TetrioPlayerNotExist {
homePlayerNickname = n;
}
} else {
homePlayerNickname = "dan63047";
}
setState(() {});
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Drawer( return Drawer(
@ -289,7 +311,7 @@ class _NavDrawerState extends State<NavDrawer> {
SliverToBoxAdapter( SliverToBoxAdapter(
child: ListTile( child: ListTile(
leading: const Icon(Icons.home), leading: const Icon(Icons.home),
title: Text(prefs.getString("player") ?? "dan63047"), title: Text(homePlayerNickname),
onTap: () { onTap: () {
developer.log("Navigator changed player", name: "main_view"); developer.log("Navigator changed player", name: "main_view");
widget.changePlayer(prefs.getString("player") ?? "dan63047"); widget.changePlayer(prefs.getString("player") ?? "dan63047");
@ -320,575 +342,6 @@ class _NavDrawerState extends State<NavDrawer> {
} }
} }
class _StatCellNum extends StatelessWidget {
const _StatCellNum({required this.playerStat, required this.playerStatLabel, required this.isScreenBig, this.snackBar, this.fractionDigits});
final num playerStat;
final String playerStatLabel;
final bool isScreenBig;
final String? snackBar;
final int? fractionDigits;
@override
Widget build(BuildContext context) {
return Column(
children: [
Text(
fractionDigits != null ? playerStat.toStringAsFixed(fractionDigits!) : playerStat.floor().toString(),
style: TextStyle(
fontFamily: "Eurostile Round Extended",
fontSize: isScreenBig ? 32 : 24,
),
),
snackBar == null
? Text(
playerStatLabel,
textAlign: TextAlign.center,
style: const TextStyle(
fontFamily: "Eurostile Round",
fontSize: 16,
),
)
: TextButton(
onPressed: () {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(snackBar!)));
},
style: ButtonStyle(padding: MaterialStateProperty.all(EdgeInsets.zero)),
child: Text(
playerStatLabel,
textAlign: TextAlign.center,
style: const TextStyle(
fontFamily: "Eurostile Round",
fontSize: 16,
),
)),
],
);
}
}
class _UserThingy extends StatelessWidget {
final TetrioPlayer player;
const _UserThingy({Key? key, required this.player}) : super(key: key);
@override
Widget build(BuildContext context) {
return LayoutBuilder(builder: (context, constraints) {
bool bigScreen = constraints.maxWidth > 768;
double bannerHeight = bigScreen ? 240 : 120;
double pfpHeight = 128;
return Column(
children: [
Flex(
direction: Axis.vertical,
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
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 const Placeholder(
color: Colors.black,
);
},
),
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.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(const SnackBar(content: Text("Copied to clipboard!")));
}),
IconButton(
icon: Icon(Icons.addchart),
style: ButtonStyle(),
onPressed: () {},
),
Text("Track")
],
)),
],
),
(player.role != "banned")
? Wrap(
direction: Axis.horizontal,
alignment: WrapAlignment.center,
spacing: 25,
crossAxisAlignment: WrapCrossAlignment.start,
clipBehavior: Clip.hardEdge, // hard WHAT???
children: [
_StatCellNum(
playerStat: player.level,
playerStatLabel: "XP Level",
isScreenBig: bigScreen,
snackBar: "${player.xp.floor().toString()} XP, ${((player.level - player.level.floor()) * 100).toStringAsFixed(2)} % until next level",
),
if (player.gameTime >= Duration.zero)
_StatCellNum(
playerStat: player.gameTime.inHours, playerStatLabel: "Hours\nPlayed", isScreenBig: bigScreen, snackBar: player.gameTime.toString()),
if (player.gamesPlayed >= 0) _StatCellNum(playerStat: player.gamesPlayed, isScreenBig: bigScreen, playerStatLabel: "Online\nGames"),
if (player.gamesWon >= 0) _StatCellNum(playerStat: player.gamesWon, isScreenBig: bigScreen, playerStatLabel: "Games\nWon"),
if (player.friendCount > 0) _StatCellNum(playerStat: player.friendCount, isScreenBig: bigScreen, playerStatLabel: "Friends"),
],
)
: Text(
"BANNED",
textAlign: TextAlign.center,
style: TextStyle(
fontFamily: "Eurostile Round Extended",
fontWeight: FontWeight.w900,
color: Colors.red,
fontSize: bigScreen ? 60 : 45,
),
),
if (player.badstanding != null && player.badstanding!)
Text(
"BAD STANDING",
textAlign: TextAlign.center,
style: TextStyle(
fontFamily: "Eurostile Round Extended",
fontWeight: FontWeight.w900,
color: Colors.red,
fontSize: bigScreen ? 60 : 45,
),
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Expanded(
child: Text(
"${player.country != null ? "${player.country?.toUpperCase()}" : ""}${player.role.capitalize()} account ${player.registrationTime == null ? "that was from very beginning" : 'created ${player.registrationTime}'}${player.botmaster != null ? " by ${player.botmaster}" : ""} ${player.supporterTier == 0 ? "Not a supporter" : "Supporter tier ${player.supporterTier}"}",
textAlign: TextAlign.center,
style: const TextStyle(
fontFamily: "Eurostile Round",
fontSize: 16,
)),
)
],
),
Wrap(
direction: Axis.horizontal,
alignment: WrapAlignment.center,
spacing: 25,
crossAxisAlignment: WrapCrossAlignment.start,
clipBehavior: Clip.hardEdge,
children: [
for (var badge in player.badges)
IconButton(
onPressed: () => showDialog<void>(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: Text(
badge.label,
style: const TextStyle(fontFamily: "Eurostile Round Extended"),
),
content: SingleChildScrollView(
child: ListBody(
children: [
Wrap(
direction: Axis.horizontal,
alignment: WrapAlignment.center,
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 25,
children: [
Image.asset("res/tetrio_badges/${badge.badgeId}.png"),
Text(badge.ts != null ? "Obtained ${badge.ts}" : "That badge was assigned manualy by TETR.IO admins"),
],
)
],
),
),
actions: <Widget>[
TextButton(
child: const Text('OK'),
onPressed: () {
Navigator.of(context).pop();
},
),
],
);
},
),
tooltip: badge.label,
icon: Image.asset(
"res/tetrio_badges/${badge.badgeId}.png",
height: 64,
width: 64,
errorBuilder: (context, error, stackTrace) {
developer.log("Error with building $badge", name: "main_view", error: error, stackTrace: stackTrace);
return Image.asset("res/icons/kagari.png", height: 64, width: 64);
},
))
],
),
],
);
});
}
}
class _TLThingy extends StatelessWidget {
final TetraLeagueAlpha tl;
final String userID;
const _TLThingy({Key? key, required this.tl, required this.userID}) : super(key: key);
@override
Widget build(BuildContext context) {
return LayoutBuilder(builder: (context, constraints) {
bool bigScreen = constraints.maxWidth > 768;
return ListView.builder(
physics: const ClampingScrollPhysics(),
itemCount: 1,
itemBuilder: (BuildContext context, int index) {
return Column(
children: (tl.gamesPlayed > 0)
? [
Text("Tetra League", style: TextStyle(fontFamily: "Eurostile Round Extended", fontSize: bigScreen ? 42 : 28)),
if (tl.gamesPlayed >= 10)
Wrap(
direction: Axis.horizontal,
alignment: WrapAlignment.spaceAround,
crossAxisAlignment: WrapCrossAlignment.center,
clipBehavior: Clip.hardEdge,
children: [
userID == "5e32fc85ab319c2ab1beb07c" // 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/${tl.rank}.png", height: 128),
Column(
children: [
Text("${tl.rating.toStringAsFixed(2)} TR",
style: TextStyle(fontFamily: "Eurostile Round Extended", fontSize: bigScreen ? 42 : 28)),
Text(
"Top ${(tl.percentile * 100).toStringAsFixed(2)}% (${tl.percentileRank.toUpperCase()}) • Top Rank: ${tl.bestRank.toUpperCase()} • Glicko: ${tl.glicko?.toStringAsFixed(2)}±${tl.rd?.toStringAsFixed(2)}${tl.decaying ? ' • Decaying' : ''}",
textAlign: TextAlign.center,
),
],
),
],
)
else
Text("${10 - tl.gamesPlayed} games until being ranked",
softWrap: true,
style: TextStyle(
fontFamily: "Eurostile Round Extended",
fontSize: bigScreen ? 42 : 28,
overflow: TextOverflow.visible,
)),
Padding(
padding: const EdgeInsets.fromLTRB(0, 16, 0, 48),
child: Wrap(
direction: Axis.horizontal,
alignment: WrapAlignment.center,
spacing: 25,
crossAxisAlignment: WrapCrossAlignment.start,
clipBehavior: Clip.hardEdge,
children: [
if (tl.apm != null)
_StatCellNum(playerStat: tl.apm!, isScreenBig: bigScreen, fractionDigits: 2, playerStatLabel: "Attack\nPer Minute"),
if (tl.pps != null)
_StatCellNum(playerStat: tl.pps!, isScreenBig: bigScreen, fractionDigits: 2, playerStatLabel: "Pieces\nPer Second"),
if (tl.apm != null) _StatCellNum(playerStat: tl.vs!, isScreenBig: bigScreen, fractionDigits: 2, playerStatLabel: "Versus\nScore"),
if (tl.standing > 0) _StatCellNum(playerStat: tl.standing, isScreenBig: bigScreen, playerStatLabel: "Leaderboard\nplacement"),
if (tl.standingLocal > 0)
_StatCellNum(playerStat: tl.standingLocal, isScreenBig: bigScreen, playerStatLabel: "Country LB\nplacement"),
_StatCellNum(playerStat: tl.gamesPlayed, isScreenBig: bigScreen, playerStatLabel: "Games\nplayed"),
_StatCellNum(playerStat: tl.gamesWon, isScreenBig: bigScreen, playerStatLabel: "Games\nwon"),
_StatCellNum(playerStat: tl.winrate * 100, isScreenBig: bigScreen, fractionDigits: 2, playerStatLabel: "Winrate\nprecentage"),
],
),
),
if (tl.nerdStats != null)
Column(
children: [
Text("Nerd Stats", style: TextStyle(fontFamily: "Eurostile Round Extended", fontSize: bigScreen ? 42 : 28)),
Padding(
padding: const EdgeInsets.fromLTRB(0, 16, 0, 48),
child: Wrap(
direction: Axis.horizontal,
alignment: WrapAlignment.center,
spacing: 25,
crossAxisAlignment: WrapCrossAlignment.start,
clipBehavior: Clip.hardEdge,
children: [
_StatCellNum(playerStat: tl.nerdStats!.app, isScreenBig: bigScreen, fractionDigits: 3, playerStatLabel: "Attack\nPer Piece"),
_StatCellNum(playerStat: tl.nerdStats!.vsapm, isScreenBig: bigScreen, fractionDigits: 3, playerStatLabel: "VS/APM"),
_StatCellNum(
playerStat: tl.nerdStats!.dss, isScreenBig: bigScreen, fractionDigits: 3, playerStatLabel: "Downstack\nPer Second"),
_StatCellNum(
playerStat: tl.nerdStats!.dsp, isScreenBig: bigScreen, fractionDigits: 3, playerStatLabel: "Downstack\nPer Piece"),
_StatCellNum(playerStat: tl.nerdStats!.appdsp, isScreenBig: bigScreen, fractionDigits: 3, playerStatLabel: "APP + DS/P"),
_StatCellNum(playerStat: tl.nerdStats!.cheese, isScreenBig: bigScreen, fractionDigits: 2, playerStatLabel: "Cheese\nIndex"),
_StatCellNum(
playerStat: tl.nerdStats!.gbe, isScreenBig: bigScreen, fractionDigits: 3, playerStatLabel: "Garbage\nEfficiency"),
_StatCellNum(playerStat: tl.nerdStats!.nyaapp, isScreenBig: bigScreen, fractionDigits: 3, playerStatLabel: "Weighted\nAPP"),
_StatCellNum(playerStat: tl.nerdStats!.area, isScreenBig: bigScreen, fractionDigits: 1, playerStatLabel: "Area")
]),
)
],
),
if (tl.estTr != null)
Padding(
padding: const EdgeInsets.fromLTRB(0, 16, 0, 48),
child: SizedBox(
width: bigScreen ? MediaQuery.of(context).size.width * 0.4 : MediaQuery.of(context).size.width * 0.85,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
"Est. of TR:",
style: TextStyle(fontSize: 24),
),
Text(
tl.estTr!.esttr.toStringAsFixed(2),
style: const TextStyle(fontSize: 24),
),
],
),
if (tl.rating >= 0)
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
"Accuracy:",
style: TextStyle(fontSize: 24),
),
Text(
tl.esttracc!.toStringAsFixed(2),
style: const TextStyle(fontSize: 24),
),
],
),
],
),
),
),
if (tl.nerdStats != null)
Wrap(
direction: Axis.horizontal,
alignment: WrapAlignment.spaceAround,
spacing: 25,
crossAxisAlignment: WrapCrossAlignment.start,
clipBehavior: Clip.hardEdge,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 48),
child: SizedBox(
height: 300,
width: 300,
child: RadarChart(
RadarChartData(
radarShape: RadarShape.polygon,
tickCount: 4,
ticksTextStyle: const TextStyle(color: Colors.transparent, fontSize: 10),
radarBorderData: const BorderSide(color: Colors.transparent, width: 1),
gridBorderData: const BorderSide(color: Colors.white24, width: 1),
tickBorderData: const BorderSide(color: Colors.transparent, width: 1),
getTitle: (index, angle) {
switch (index) {
case 0:
return RadarChartTitle(
text: 'APM',
angle: angle,
);
case 1:
return RadarChartTitle(
text: 'PPS',
angle: angle,
);
case 2:
return RadarChartTitle(text: 'VS', angle: angle);
case 3:
return RadarChartTitle(text: 'APP', angle: angle + 180);
case 4:
return RadarChartTitle(text: 'DS/S', angle: angle + 180);
case 5:
return RadarChartTitle(text: 'DS/P', angle: angle + 180);
case 6:
return RadarChartTitle(text: 'APP+DS/P', angle: angle + 180);
case 7:
return RadarChartTitle(text: 'VS/APM', angle: angle + 180);
case 8:
return RadarChartTitle(text: 'Cheese', angle: angle);
case 9:
return RadarChartTitle(text: 'Gb Eff.', angle: angle);
default:
return const RadarChartTitle(text: '');
}
},
dataSets: [
RadarDataSet(
dataEntries: [
RadarEntry(value: tl.apm! * 1),
RadarEntry(value: tl.pps! * 45),
RadarEntry(value: tl.vs! * 0.444),
RadarEntry(value: tl.nerdStats!.app * 185),
RadarEntry(value: tl.nerdStats!.dss * 175),
RadarEntry(value: tl.nerdStats!.dsp * 450),
RadarEntry(value: tl.nerdStats!.appdsp * 140),
RadarEntry(value: tl.nerdStats!.vsapm * 60),
RadarEntry(value: tl.nerdStats!.cheese * 1.25),
RadarEntry(value: tl.nerdStats!.gbe * 315),
],
),
RadarDataSet(
fillColor: Colors.transparent,
borderColor: Colors.transparent,
dataEntries: [
const RadarEntry(value: 0),
const RadarEntry(value: 0),
const RadarEntry(value: 0),
const RadarEntry(value: 0),
const RadarEntry(value: 0),
const RadarEntry(value: 0),
const RadarEntry(value: 0),
const RadarEntry(value: 0),
const RadarEntry(value: 0),
const RadarEntry(value: 0),
],
)
],
),
swapAnimationDuration: const Duration(milliseconds: 150), // Optional
swapAnimationCurve: Curves.linear, // Optional
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 48),
child: SizedBox(
height: 300,
width: 300,
child: RadarChart(
RadarChartData(
radarShape: RadarShape.polygon,
tickCount: 4,
ticksTextStyle: const TextStyle(color: Colors.transparent, fontSize: 10),
radarBorderData: const BorderSide(color: Colors.transparent, width: 1),
gridBorderData: const BorderSide(color: Colors.white24, width: 1),
tickBorderData: const BorderSide(color: Colors.transparent, width: 1),
getTitle: (index, angle) {
switch (index) {
case 0:
return RadarChartTitle(
text: 'Opener',
angle: angle,
);
case 1:
return RadarChartTitle(
text: 'Stride',
angle: angle,
);
case 2:
return RadarChartTitle(text: 'Inf Ds', angle: angle + 180);
case 3:
return RadarChartTitle(text: 'Plonk', angle: angle);
default:
return const RadarChartTitle(text: '');
}
},
dataSets: [
RadarDataSet(
dataEntries: [
RadarEntry(value: tl.playstyle!.opener),
RadarEntry(value: tl.playstyle!.stride),
RadarEntry(value: tl.playstyle!.infds),
RadarEntry(value: tl.playstyle!.plonk),
],
),
RadarDataSet(
fillColor: Colors.transparent,
borderColor: Colors.transparent,
dataEntries: [
const RadarEntry(value: 0),
const RadarEntry(value: 0),
const RadarEntry(value: 0),
const RadarEntry(value: 0),
],
),
RadarDataSet(
fillColor: Colors.transparent,
borderColor: Colors.transparent,
dataEntries: [
const RadarEntry(value: 1),
const RadarEntry(value: 1),
const RadarEntry(value: 1),
const RadarEntry(value: 1),
],
)
],
),
swapAnimationDuration: const Duration(milliseconds: 150), // Optional
swapAnimationCurve: Curves.linear, // Optional
),
),
),
],
)
]
: [
Text("That user never played Tetra League", style: TextStyle(fontFamily: "Eurostile Round Extended", fontSize: bigScreen ? 42 : 28)),
],
);
},
);
});
}
}
class _RecordThingy extends StatelessWidget { class _RecordThingy extends StatelessWidget {
final RecordSingle? record; final RecordSingle? record;
const _RecordThingy({Key? key, required this.record}) : super(key: key); const _RecordThingy({Key? key, required this.record}) : super(key: key);
@ -912,7 +365,7 @@ class _RecordThingy extends StatelessWidget {
Text(record!.endContext!.finalTime.toString(), style: TextStyle(fontFamily: "Eurostile Round Extended", fontSize: bigScreen ? 42 : 28)) Text(record!.endContext!.finalTime.toString(), style: TextStyle(fontFamily: "Eurostile Round Extended", fontSize: bigScreen ? 42 : 28))
else if (record!.stream.contains("blitz")) else if (record!.stream.contains("blitz"))
Text(record!.endContext!.score.toString(), style: TextStyle(fontFamily: "Eurostile Round Extended", fontSize: bigScreen ? 42 : 28)), Text(record!.endContext!.score.toString(), style: TextStyle(fontFamily: "Eurostile Round Extended", fontSize: bigScreen ? 42 : 28)),
if (record!.rank != null) _StatCellNum(playerStat: record!.rank!, playerStatLabel: "Leaderboard Placement", isScreenBig: bigScreen), if (record!.rank != null) StatCellNum(playerStat: record!.rank!, playerStatLabel: "Leaderboard Placement", isScreenBig: bigScreen),
Padding( Padding(
padding: const EdgeInsets.fromLTRB(0, 48, 0, 48), padding: const EdgeInsets.fromLTRB(0, 48, 0, 48),
child: Wrap( child: Wrap(
@ -923,20 +376,20 @@ class _RecordThingy extends StatelessWidget {
spacing: 25, spacing: 25,
children: [ children: [
if (record!.stream.contains("blitz")) if (record!.stream.contains("blitz"))
_StatCellNum(playerStat: record!.endContext!.level, playerStatLabel: "Level", isScreenBig: bigScreen), StatCellNum(playerStat: record!.endContext!.level, playerStatLabel: "Level", isScreenBig: bigScreen),
if (record!.stream.contains("blitz")) if (record!.stream.contains("blitz"))
_StatCellNum(playerStat: record!.endContext!.spp, playerStatLabel: "Score\nPer Piece", fractionDigits: 2, isScreenBig: bigScreen), StatCellNum(playerStat: record!.endContext!.spp, playerStatLabel: "Score\nPer Piece", fractionDigits: 2, isScreenBig: bigScreen),
_StatCellNum(playerStat: record!.endContext!.piecesPlaced, playerStatLabel: "Pieces\nPlaced", isScreenBig: bigScreen), StatCellNum(playerStat: record!.endContext!.piecesPlaced, playerStatLabel: "Pieces\nPlaced", isScreenBig: bigScreen),
_StatCellNum(playerStat: record!.endContext!.pps, playerStatLabel: "Pieces\nPer Second", fractionDigits: 2, isScreenBig: bigScreen), StatCellNum(playerStat: record!.endContext!.pps, playerStatLabel: "Pieces\nPer Second", fractionDigits: 2, isScreenBig: bigScreen),
_StatCellNum(playerStat: record!.endContext!.finesse.faults, playerStatLabel: "Finesse\nFaults", isScreenBig: bigScreen), StatCellNum(playerStat: record!.endContext!.finesse.faults, playerStatLabel: "Finesse\nFaults", isScreenBig: bigScreen),
_StatCellNum( StatCellNum(
playerStat: record!.endContext!.finessePercentage * 100, playerStat: record!.endContext!.finessePercentage * 100,
playerStatLabel: "Finesse\nPercentage", playerStatLabel: "Finesse\nPercentage",
fractionDigits: 2, fractionDigits: 2,
isScreenBig: bigScreen), isScreenBig: bigScreen),
_StatCellNum(playerStat: record!.endContext!.inputs, playerStatLabel: "Key\nPresses", isScreenBig: bigScreen), StatCellNum(playerStat: record!.endContext!.inputs, playerStatLabel: "Key\nPresses", isScreenBig: bigScreen),
_StatCellNum(playerStat: record!.endContext!.kpp, playerStatLabel: "KP Per\nPiece", fractionDigits: 2, isScreenBig: bigScreen), StatCellNum(playerStat: record!.endContext!.kpp, playerStatLabel: "KP Per\nPiece", fractionDigits: 2, isScreenBig: bigScreen),
_StatCellNum(playerStat: record!.endContext!.kps, playerStatLabel: "KP Per\nSecond", fractionDigits: 2, isScreenBig: bigScreen), StatCellNum(playerStat: record!.endContext!.kps, playerStatLabel: "KP Per\nSecond", fractionDigits: 2, isScreenBig: bigScreen),
], ],
), ),
), ),

View File

@ -1,6 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:package_info_plus/package_info_plus.dart'; import 'package:package_info_plus/package_info_plus.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:tetra_stats/services/crud_exceptions.dart';
import 'package:tetra_stats/services/tetrio_crud.dart';
class SettingsView extends StatefulWidget { class SettingsView extends StatefulWidget {
const SettingsView({Key? key}) : super(key: key); const SettingsView({Key? key}) : super(key: key);
@ -12,13 +14,15 @@ class SettingsView extends StatefulWidget {
class SettingsState extends State<SettingsView> { class SettingsState extends State<SettingsView> {
PackageInfo _packageInfo = PackageInfo(appName: "TetraStats", packageName: "idk man", version: "some numbers", buildNumber: "anotherNumber"); PackageInfo _packageInfo = PackageInfo(appName: "TetraStats", packageName: "idk man", version: "some numbers", buildNumber: "anotherNumber");
late SharedPreferences prefs; late SharedPreferences prefs;
final TetrioService teto = TetrioService();
String defaultNickname = "Checking...";
final TextEditingController _playertext = TextEditingController(); final TextEditingController _playertext = TextEditingController();
@override @override
void initState() { void initState() {
super.initState();
_initPackageInfo(); _initPackageInfo();
_getPreferences(); _getPreferences();
super.initState();
} }
Future<void> _initPackageInfo() async { Future<void> _initPackageInfo() async {
@ -30,10 +34,25 @@ class SettingsState extends State<SettingsView> {
Future<void> _getPreferences() async { Future<void> _getPreferences() async {
prefs = await SharedPreferences.getInstance(); prefs = await SharedPreferences.getInstance();
_setDefaultNickname(prefs.getString("player"));
}
Future<void> _setDefaultNickname(String? n) async {
if (n != null) {
try {
defaultNickname = await teto.getNicknameByID(n);
} on TetrioPlayerNotExist {
defaultNickname = n;
}
} else {
defaultNickname = "dan63047";
}
setState(() {});
} }
Future<void> _setPlayer(String player) async { Future<void> _setPlayer(String player) async {
await prefs.setString('player', player); await prefs.setString('player', player);
await _setDefaultNickname(player);
} }
@override @override
@ -48,24 +67,25 @@ class SettingsState extends State<SettingsView> {
children: [ children: [
ListTile( ListTile(
title: const Text("So there you gonna be able to change some settings"), title: const Text("So there you gonna be able to change some settings"),
subtitle: const Text( subtitle: const Text("Only \"Your TETR.IO account\" implemented yet. In the future you will able to import and export app sqlite database."),
"Only \"Your TETR.IO account nickname or ID\" implemented yet. But its gonna be possible to change player for main view init, save logs, as well as import and export app sqlite database."),
trailing: Switch( trailing: Switch(
value: true, value: true,
onChanged: (bool value) {}, onChanged: (bool value) {},
), ),
), ),
ListTile( ListTile(
title: const Text("Your TETR.IO account nickname or ID"), title: const Text("Your TETR.IO account"),
subtitle: trailing: Text(defaultNickname),
const Text("Every time when app loads, stats of that player will be fetched. Please prefer ID over nickname because nickname can be changed."),
trailing: Text(prefs.getString("player") ?? "dan63047"),
onTap: () => showDialog( onTap: () => showDialog(
context: context, context: context,
builder: (BuildContext context) => AlertDialog( builder: (BuildContext context) => AlertDialog(
title: const Text("Your TETR.IO account nickname or ID", style: TextStyle(fontFamily: "Eurostile Round Extended")), title: const Text("Your TETR.IO account nickname or ID", style: TextStyle(fontFamily: "Eurostile Round Extended")),
content: SingleChildScrollView( content: SingleChildScrollView(
child: ListBody(children: [TextField(controller: _playertext, maxLength: 25)]), child: ListBody(children: [
const Text(
"Every time when app loads, stats of that player will be fetched. Please prefer ID over nickname because nickname can be changed."),
TextField(controller: _playertext, maxLength: 25)
]),
), ),
actions: <Widget>[ actions: <Widget>[
TextButton( TextButton(

49
lib/views/state_view.dart Normal file
View File

@ -0,0 +1,49 @@
import 'package:flutter/material.dart';
import 'package:tetra_stats/data_objects/tetrio.dart';
import 'package:tetra_stats/widgets/tl_thingy.dart';
import 'package:tetra_stats/widgets/user_thingy.dart';
class StateView extends StatefulWidget {
final TetrioPlayer state;
const StateView({Key? key, required this.state}) : super(key: key);
@override
State<StatefulWidget> createState() => StateState();
}
class StateState extends State<StateView> {
late ScrollController _scrollController;
@override
void initState() {
_scrollController = ScrollController();
super.initState();
}
void _justUpdate() {
setState(() {});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text("${widget.state.username.toUpperCase()} account on ${widget.state.state}"),
),
backgroundColor: Colors.black,
body: SafeArea(
child: NestedScrollView(
controller: _scrollController,
headerSliverBuilder: (context, value) {
return [
SliverToBoxAdapter(
child: UserThingy(
player: widget.state,
showStateTimestamp: true,
setState: _justUpdate,
))
];
},
body: TLThingy(tl: widget.state.tlSeason1, userID: widget.state.userId))));
}
}

View File

@ -0,0 +1,43 @@
import 'package:flutter/material.dart';
import 'package:tetra_stats/data_objects/tetrio.dart';
import 'package:tetra_stats/views/state_view.dart';
class StatesView extends StatefulWidget {
final List<TetrioPlayer> states;
const StatesView({Key? key, required this.states}) : super(key: key);
@override
State<StatefulWidget> createState() => StatesState();
}
class StatesState extends State<StatesView> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text("${widget.states.length} states of ${widget.states.last.username.toUpperCase()} account"),
),
backgroundColor: Colors.black,
body: SafeArea(
child: ListView.builder(
itemCount: widget.states.length,
itemBuilder: (context, index) {
return ListTile(
title: Text("On ${widget.states[index].state}"),
subtitle: Text("Level ${widget.states[index].level.toStringAsFixed(2)} level, ${widget.states[index].gameTime} of gametime"),
trailing: IconButton(
icon: const Icon(Icons.delete_forever),
onPressed: () {},
),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => StateView(state: widget.states[index]),
),
);
},
);
})));
}
}

View File

@ -1,22 +1,23 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:tetra_stats/data_objects/tetrio.dart'; import 'package:tetra_stats/data_objects/tetrio.dart';
import 'package:tetra_stats/services/tetrio_crud.dart'; import 'package:tetra_stats/services/tetrio_crud.dart';
import 'package:tetra_stats/views/states_view.dart';
final TetrioService teto = TetrioService(); final TetrioService teto = TetrioService();
class StatesView extends StatefulWidget { class TrackedPlayersView extends StatefulWidget {
const StatesView({Key? key}) : super(key: key); const TrackedPlayersView({Key? key}) : super(key: key);
@override @override
State<StatefulWidget> createState() => StatesState(); State<StatefulWidget> createState() => TrackedPlayersState();
} }
class StatesState extends State<StatesView> { class TrackedPlayersState extends State<TrackedPlayersView> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text("Players you track"), title: const Text("Stored data"),
), ),
backgroundColor: Colors.black, backgroundColor: Colors.black,
body: SafeArea( body: SafeArea(
@ -38,7 +39,7 @@ class StatesState extends State<StatesView> {
child: Padding( child: Padding(
padding: const EdgeInsets.only(left: 16), padding: const EdgeInsets.only(left: 16),
child: Text( child: Text(
'There is ${allPlayers.length} players', 'There are ${allPlayers.length} players',
style: TextStyle(color: Colors.white, fontSize: 25), style: TextStyle(color: Colors.white, fontSize: 25),
), ),
)), )),
@ -51,7 +52,18 @@ class StatesState extends State<StatesView> {
return ListTile( return ListTile(
title: Text("${allPlayers[keys[index]]?.last.username}: ${allPlayers[keys[index]]?.length} states"), title: Text("${allPlayers[keys[index]]?.last.username}: ${allPlayers[keys[index]]?.length} states"),
subtitle: Text("From ${allPlayers[keys[index]]?.first.state} until ${allPlayers[keys[index]]?.last.state}"), subtitle: Text("From ${allPlayers[keys[index]]?.first.state} until ${allPlayers[keys[index]]?.last.state}"),
onTap: () {}, trailing: IconButton(
icon: const Icon(Icons.delete_forever),
onPressed: () {},
),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => StatesView(states: allPlayers[keys[index]]!),
),
);
},
); );
})); }));
case ConnectionState.done: case ConnectionState.done:

View File

@ -0,0 +1,48 @@
import 'package:flutter/material.dart';
class StatCellNum extends StatelessWidget {
const StatCellNum({required this.playerStat, required this.playerStatLabel, required this.isScreenBig, this.snackBar, this.fractionDigits});
final num playerStat;
final String playerStatLabel;
final bool isScreenBig;
final String? snackBar;
final int? fractionDigits;
@override
Widget build(BuildContext context) {
return Column(
children: [
Text(
fractionDigits != null ? playerStat.toStringAsFixed(fractionDigits!) : playerStat.floor().toString(),
style: TextStyle(
fontFamily: "Eurostile Round Extended",
fontSize: isScreenBig ? 32 : 24,
),
),
snackBar == null
? Text(
playerStatLabel,
textAlign: TextAlign.center,
style: const TextStyle(
fontFamily: "Eurostile Round",
fontSize: 16,
),
)
: TextButton(
onPressed: () {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(snackBar!)));
},
style: ButtonStyle(padding: MaterialStateProperty.all(EdgeInsets.zero)),
child: Text(
playerStatLabel,
textAlign: TextAlign.center,
style: const TextStyle(
fontFamily: "Eurostile Round",
fontSize: 16,
),
)),
],
);
}
}

315
lib/widgets/tl_thingy.dart Normal file
View File

@ -0,0 +1,315 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:tetra_stats/data_objects/tetrio.dart';
import 'dart:developer' as developer;
import 'package:fl_chart/fl_chart.dart';
import 'package:tetra_stats/widgets/stat_sell_num.dart';
class TLThingy extends StatelessWidget {
final TetraLeagueAlpha tl;
final String userID;
const TLThingy({Key? key, required this.tl, required this.userID}) : super(key: key);
@override
Widget build(BuildContext context) {
return LayoutBuilder(builder: (context, constraints) {
bool bigScreen = constraints.maxWidth > 768;
return ListView.builder(
physics: const ClampingScrollPhysics(),
itemCount: 1,
itemBuilder: (BuildContext context, int index) {
return Column(
children: (tl.gamesPlayed > 0)
? [
Text("Tetra League", style: TextStyle(fontFamily: "Eurostile Round Extended", fontSize: bigScreen ? 42 : 28)),
if (tl.gamesPlayed >= 10)
Wrap(
direction: Axis.horizontal,
alignment: WrapAlignment.spaceAround,
crossAxisAlignment: WrapCrossAlignment.center,
clipBehavior: Clip.hardEdge,
children: [
userID == "5e32fc85ab319c2ab1beb07c" // 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/${tl.rank}.png", height: 128),
Column(
children: [
Text("${tl.rating.toStringAsFixed(2)} TR",
style: TextStyle(fontFamily: "Eurostile Round Extended", fontSize: bigScreen ? 42 : 28)),
Text(
"Top ${(tl.percentile * 100).toStringAsFixed(2)}% (${tl.percentileRank.toUpperCase()}) • Top Rank: ${tl.bestRank.toUpperCase()} • Glicko: ${tl.glicko?.toStringAsFixed(2)}±${tl.rd?.toStringAsFixed(2)}${tl.decaying ? ' • Decaying' : ''}",
textAlign: TextAlign.center,
),
],
),
],
)
else
Text("${10 - tl.gamesPlayed} games until being ranked",
softWrap: true,
style: TextStyle(
fontFamily: "Eurostile Round Extended",
fontSize: bigScreen ? 42 : 28,
overflow: TextOverflow.visible,
)),
Padding(
padding: const EdgeInsets.fromLTRB(0, 16, 0, 48),
child: Wrap(
direction: Axis.horizontal,
alignment: WrapAlignment.center,
spacing: 25,
crossAxisAlignment: WrapCrossAlignment.start,
clipBehavior: Clip.hardEdge,
children: [
if (tl.apm != null)
StatCellNum(playerStat: tl.apm!, isScreenBig: bigScreen, fractionDigits: 2, playerStatLabel: "Attack\nPer Minute"),
if (tl.pps != null)
StatCellNum(playerStat: tl.pps!, isScreenBig: bigScreen, fractionDigits: 2, playerStatLabel: "Pieces\nPer Second"),
if (tl.apm != null) StatCellNum(playerStat: tl.vs!, isScreenBig: bigScreen, fractionDigits: 2, playerStatLabel: "Versus\nScore"),
if (tl.standing > 0) StatCellNum(playerStat: tl.standing, isScreenBig: bigScreen, playerStatLabel: "Leaderboard\nplacement"),
if (tl.standingLocal > 0) StatCellNum(playerStat: tl.standingLocal, isScreenBig: bigScreen, playerStatLabel: "Country LB\nplacement"),
StatCellNum(playerStat: tl.gamesPlayed, isScreenBig: bigScreen, playerStatLabel: "Games\nplayed"),
StatCellNum(playerStat: tl.gamesWon, isScreenBig: bigScreen, playerStatLabel: "Games\nwon"),
StatCellNum(playerStat: tl.winrate * 100, isScreenBig: bigScreen, fractionDigits: 2, playerStatLabel: "Winrate\nprecentage"),
],
),
),
if (tl.nerdStats != null)
Column(
children: [
Text("Nerd Stats", style: TextStyle(fontFamily: "Eurostile Round Extended", fontSize: bigScreen ? 42 : 28)),
Padding(
padding: const EdgeInsets.fromLTRB(0, 16, 0, 48),
child: Wrap(
direction: Axis.horizontal,
alignment: WrapAlignment.center,
spacing: 25,
crossAxisAlignment: WrapCrossAlignment.start,
clipBehavior: Clip.hardEdge,
children: [
StatCellNum(playerStat: tl.nerdStats!.app, isScreenBig: bigScreen, fractionDigits: 3, playerStatLabel: "Attack\nPer Piece"),
StatCellNum(playerStat: tl.nerdStats!.vsapm, isScreenBig: bigScreen, fractionDigits: 3, playerStatLabel: "VS/APM"),
StatCellNum(
playerStat: tl.nerdStats!.dss, isScreenBig: bigScreen, fractionDigits: 3, playerStatLabel: "Downstack\nPer Second"),
StatCellNum(
playerStat: tl.nerdStats!.dsp, isScreenBig: bigScreen, fractionDigits: 3, playerStatLabel: "Downstack\nPer Piece"),
StatCellNum(playerStat: tl.nerdStats!.appdsp, isScreenBig: bigScreen, fractionDigits: 3, playerStatLabel: "APP + DS/P"),
StatCellNum(playerStat: tl.nerdStats!.cheese, isScreenBig: bigScreen, fractionDigits: 2, playerStatLabel: "Cheese\nIndex"),
StatCellNum(playerStat: tl.nerdStats!.gbe, isScreenBig: bigScreen, fractionDigits: 3, playerStatLabel: "Garbage\nEfficiency"),
StatCellNum(playerStat: tl.nerdStats!.nyaapp, isScreenBig: bigScreen, fractionDigits: 3, playerStatLabel: "Weighted\nAPP"),
StatCellNum(playerStat: tl.nerdStats!.area, isScreenBig: bigScreen, fractionDigits: 1, playerStatLabel: "Area")
]),
)
],
),
if (tl.estTr != null)
Padding(
padding: const EdgeInsets.fromLTRB(0, 16, 0, 48),
child: SizedBox(
width: bigScreen ? MediaQuery.of(context).size.width * 0.4 : MediaQuery.of(context).size.width * 0.85,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
"Est. of TR:",
style: TextStyle(fontSize: 24),
),
Text(
tl.estTr!.esttr.toStringAsFixed(2),
style: const TextStyle(fontSize: 24),
),
],
),
if (tl.rating >= 0)
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
"Accuracy:",
style: TextStyle(fontSize: 24),
),
Text(
tl.esttracc!.toStringAsFixed(2),
style: const TextStyle(fontSize: 24),
),
],
),
],
),
),
),
if (tl.nerdStats != null)
Wrap(
direction: Axis.horizontal,
alignment: WrapAlignment.spaceAround,
spacing: 25,
crossAxisAlignment: WrapCrossAlignment.start,
clipBehavior: Clip.hardEdge,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 48),
child: SizedBox(
height: 300,
width: 300,
child: RadarChart(
RadarChartData(
radarShape: RadarShape.polygon,
tickCount: 4,
ticksTextStyle: const TextStyle(color: Colors.transparent, fontSize: 10),
radarBorderData: const BorderSide(color: Colors.transparent, width: 1),
gridBorderData: const BorderSide(color: Colors.white24, width: 1),
tickBorderData: const BorderSide(color: Colors.transparent, width: 1),
getTitle: (index, angle) {
switch (index) {
case 0:
return RadarChartTitle(
text: 'APM',
angle: angle,
);
case 1:
return RadarChartTitle(
text: 'PPS',
angle: angle,
);
case 2:
return RadarChartTitle(text: 'VS', angle: angle);
case 3:
return RadarChartTitle(text: 'APP', angle: angle + 180);
case 4:
return RadarChartTitle(text: 'DS/S', angle: angle + 180);
case 5:
return RadarChartTitle(text: 'DS/P', angle: angle + 180);
case 6:
return RadarChartTitle(text: 'APP+DS/P', angle: angle + 180);
case 7:
return RadarChartTitle(text: 'VS/APM', angle: angle + 180);
case 8:
return RadarChartTitle(text: 'Cheese', angle: angle);
case 9:
return RadarChartTitle(text: 'Gb Eff.', angle: angle);
default:
return const RadarChartTitle(text: '');
}
},
dataSets: [
RadarDataSet(
dataEntries: [
RadarEntry(value: tl.apm! * 1),
RadarEntry(value: tl.pps! * 45),
RadarEntry(value: tl.vs! * 0.444),
RadarEntry(value: tl.nerdStats!.app * 185),
RadarEntry(value: tl.nerdStats!.dss * 175),
RadarEntry(value: tl.nerdStats!.dsp * 450),
RadarEntry(value: tl.nerdStats!.appdsp * 140),
RadarEntry(value: tl.nerdStats!.vsapm * 60),
RadarEntry(value: tl.nerdStats!.cheese * 1.25),
RadarEntry(value: tl.nerdStats!.gbe * 315),
],
),
RadarDataSet(
fillColor: Colors.transparent,
borderColor: Colors.transparent,
dataEntries: [
const RadarEntry(value: 0),
const RadarEntry(value: 0),
const RadarEntry(value: 0),
const RadarEntry(value: 0),
const RadarEntry(value: 0),
const RadarEntry(value: 0),
const RadarEntry(value: 0),
const RadarEntry(value: 0),
const RadarEntry(value: 0),
const RadarEntry(value: 0),
],
)
],
),
swapAnimationDuration: const Duration(milliseconds: 150), // Optional
swapAnimationCurve: Curves.linear, // Optional
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 48),
child: SizedBox(
height: 300,
width: 300,
child: RadarChart(
RadarChartData(
radarShape: RadarShape.polygon,
tickCount: 4,
ticksTextStyle: const TextStyle(color: Colors.transparent, fontSize: 10),
radarBorderData: const BorderSide(color: Colors.transparent, width: 1),
gridBorderData: const BorderSide(color: Colors.white24, width: 1),
tickBorderData: const BorderSide(color: Colors.transparent, width: 1),
getTitle: (index, angle) {
switch (index) {
case 0:
return RadarChartTitle(
text: 'Opener',
angle: angle,
);
case 1:
return RadarChartTitle(
text: 'Stride',
angle: angle,
);
case 2:
return RadarChartTitle(text: 'Inf Ds', angle: angle + 180);
case 3:
return RadarChartTitle(text: 'Plonk', angle: angle);
default:
return const RadarChartTitle(text: '');
}
},
dataSets: [
RadarDataSet(
dataEntries: [
RadarEntry(value: tl.playstyle!.opener),
RadarEntry(value: tl.playstyle!.stride),
RadarEntry(value: tl.playstyle!.infds),
RadarEntry(value: tl.playstyle!.plonk),
],
),
RadarDataSet(
fillColor: Colors.transparent,
borderColor: Colors.transparent,
dataEntries: [
const RadarEntry(value: 0),
const RadarEntry(value: 0),
const RadarEntry(value: 0),
const RadarEntry(value: 0),
],
),
RadarDataSet(
fillColor: Colors.transparent,
borderColor: Colors.transparent,
dataEntries: [
const RadarEntry(value: 1),
const RadarEntry(value: 1),
const RadarEntry(value: 1),
const RadarEntry(value: 1),
],
)
],
),
swapAnimationDuration: const Duration(milliseconds: 150), // Optional
swapAnimationCurve: Curves.linear, // Optional
),
),
),
],
)
]
: [
Text("That user never played Tetra League", style: TextStyle(fontFamily: "Eurostile Round Extended", fontSize: bigScreen ? 42 : 28)),
],
);
},
);
});
}
}

View File

@ -0,0 +1,274 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:tetra_stats/data_objects/tetrio.dart';
import 'package:tetra_stats/views/tracked_players_view.dart';
import 'dart:developer' as developer;
import 'package:tetra_stats/widgets/stat_sell_num.dart';
extension StringExtension on String {
String capitalize() {
return "${this[0].toUpperCase()}${substring(1).toLowerCase()}";
}
}
Future<void> copyToClipboard(String text) async {
await Clipboard.setData(ClipboardData(text: text));
}
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
Widget build(BuildContext context) {
return LayoutBuilder(builder: (context, constraints) {
bool bigScreen = constraints.maxWidth > 768;
double bannerHeight = bigScreen ? 240 : 120;
double pfpHeight = 128;
return Column(
children: [
Flex(
direction: Axis.vertical,
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
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 const Placeholder(
color: Colors.black,
);
},
),
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.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(const SnackBar(content: Text("Copied to clipboard!")));
}),
],
)),
showStateTimestamp
? Text("Fetched ${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: Icon(Icons.person_remove),
onPressed: () {
teto.deletePlayerToTrack(player.userId).then((value) => setState());
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("Removed from tracking list!")));
},
),
Text("Stop tracking")
],
);
} else {
return Column(
children: [
IconButton(
icon: Icon(Icons.person_add),
onPressed: () {
teto.addPlayerToTrack(player).then((value) => setState());
teto.storeState(player);
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("Added to tracking list!")));
},
),
Text("Track")
],
);
}
}
}),
Column(
children: [
IconButton(
icon: Icon(Icons.balance),
onPressed: () {},
),
Text("Compare")
],
)
]),
],
),
(player.role != "banned")
? Wrap(
direction: Axis.horizontal,
alignment: WrapAlignment.center,
spacing: 25,
crossAxisAlignment: WrapCrossAlignment.start,
clipBehavior: Clip.hardEdge, // hard WHAT???
children: [
StatCellNum(
playerStat: player.level,
playerStatLabel: "XP Level",
isScreenBig: bigScreen,
snackBar: "${player.xp.floor().toString()} XP, ${((player.level - player.level.floor()) * 100).toStringAsFixed(2)} % until next level",
),
if (player.gameTime >= Duration.zero)
StatCellNum(
playerStat: player.gameTime.inHours, playerStatLabel: "Hours\nPlayed", isScreenBig: bigScreen, snackBar: player.gameTime.toString()),
if (player.gamesPlayed >= 0) StatCellNum(playerStat: player.gamesPlayed, isScreenBig: bigScreen, playerStatLabel: "Online\nGames"),
if (player.gamesWon >= 0) StatCellNum(playerStat: player.gamesWon, isScreenBig: bigScreen, playerStatLabel: "Games\nWon"),
if (player.friendCount > 0) StatCellNum(playerStat: player.friendCount, isScreenBig: bigScreen, playerStatLabel: "Friends"),
],
)
: Text(
"BANNED",
textAlign: TextAlign.center,
style: TextStyle(
fontFamily: "Eurostile Round Extended",
fontWeight: FontWeight.w900,
color: Colors.red,
fontSize: bigScreen ? 60 : 45,
),
),
if (player.badstanding != null && player.badstanding!)
Text(
"BAD STANDING",
textAlign: TextAlign.center,
style: TextStyle(
fontFamily: "Eurostile Round Extended",
fontWeight: FontWeight.w900,
color: Colors.red,
fontSize: bigScreen ? 60 : 45,
),
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Expanded(
child: Text(
"${player.country != null ? "${player.country?.toUpperCase()}" : ""}${player.role.capitalize()} account ${player.registrationTime == null ? "that was from very beginning" : 'created ${player.registrationTime}'}${player.botmaster != null ? " by ${player.botmaster}" : ""} ${player.supporterTier == 0 ? "Not a supporter" : "Supporter tier ${player.supporterTier}"}",
textAlign: TextAlign.center,
style: const TextStyle(
fontFamily: "Eurostile Round",
fontSize: 16,
)),
)
],
),
Wrap(
direction: Axis.horizontal,
alignment: WrapAlignment.center,
spacing: 25,
crossAxisAlignment: WrapCrossAlignment.start,
clipBehavior: Clip.hardEdge,
children: [
for (var badge in player.badges)
IconButton(
onPressed: () => showDialog<void>(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: Text(
badge.label,
style: const TextStyle(fontFamily: "Eurostile Round Extended"),
),
content: SingleChildScrollView(
child: ListBody(
children: [
Wrap(
direction: Axis.horizontal,
alignment: WrapAlignment.center,
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 25,
children: [
Image.asset("res/tetrio_badges/${badge.badgeId}.png"),
Text(badge.ts != null ? "Obtained ${badge.ts}" : "That badge was assigned manualy by TETR.IO admins"),
],
)
],
),
),
actions: <Widget>[
TextButton(
child: const Text('OK'),
onPressed: () {
Navigator.of(context).pop();
},
),
],
);
},
),
tooltip: badge.label,
icon: Image.asset(
"res/tetrio_badges/${badge.badgeId}.png",
height: 64,
width: 64,
errorBuilder: (context, error, stackTrace) {
developer.log("Error with building $badge", name: "main_view", error: error, stackTrace: stackTrace);
return Image.asset("res/icons/kagari.png", height: 64, width: 64);
},
))
],
),
],
);
});
}
}

View File

@ -87,6 +87,7 @@ flutter:
- res/tetrio_badges/heart.png - res/tetrio_badges/heart.png
- res/tetrio_badges/hnprism_1.png - res/tetrio_badges/hnprism_1.png
- res/tetrio_badges/hnprism_2.png - res/tetrio_badges/hnprism_2.png
- res/tetrio_badges/hnprism_3.png
- res/tetrio_badges/ift_1.png - res/tetrio_badges/ift_1.png
- res/tetrio_badges/ift_2.png - res/tetrio_badges/ift_2.png
- res/tetrio_badges/ift_3.png - res/tetrio_badges/ift_3.png
@ -100,20 +101,28 @@ flutter:
- res/tetrio_badges/mmc_tabi_superlobby.png - res/tetrio_badges/mmc_tabi_superlobby.png
- res/tetrio_badges/mmc_tabi_superlobby2.png - res/tetrio_badges/mmc_tabi_superlobby2.png
- res/tetrio_badges/mmc_tabi_superlobby3.png - res/tetrio_badges/mmc_tabi_superlobby3.png
- res/tetrio_badges/redgevo_1.png
- res/tetrio_badges/redgevo_2.png - res/tetrio_badges/redgevo_2.png
- res/tetrio_badges/redgevo_3.png - res/tetrio_badges/redgevo_3.png
- res/tetrio_badges/rengervl_1.png - res/tetrio_badges/rengervl_1.png
- res/tetrio_badges/rengervl_2.png - res/tetrio_badges/rengervl_2.png
- res/tetrio_badges/rengervl_3.png - res/tetrio_badges/rengervl_3.png
- res/tetrio_badges/sakurablend_1.png - res/tetrio_badges/sakurablend_1.png
- res/tetrio_badges/sakurablend_2.png
- res/tetrio_badges/sakurablend_3.png
- res/tetrio_badges/scuncapped_1.png - res/tetrio_badges/scuncapped_1.png
- res/tetrio_badges/scuncapped_2.png - res/tetrio_badges/scuncapped_2.png
- res/tetrio_badges/scuncapped_3.png
- res/tetrio_badges/secretgrade.png - res/tetrio_badges/secretgrade.png
- res/tetrio_badges/sfu_raccoon_1.png - res/tetrio_badges/sfu_raccoon_1.png
- res/tetrio_badges/sfu_raccoon_2.png
- res/tetrio_badges/sfu_raccoon_3.png
- res/tetrio_badges/superlobby.png - res/tetrio_badges/superlobby.png
- res/tetrio_badges/superlobby2.png - res/tetrio_badges/superlobby2.png
- res/tetrio_badges/taws_u50_1.png - res/tetrio_badges/taws_u50_1.png
- res/tetrio_badges/taws_u50_2.png
- res/tetrio_badges/taws_u50_3.png - res/tetrio_badges/taws_u50_3.png
- res/tetrio_badges/tawshdsl_capped.png
- res/tetrio_badges/tawshdsl_uncapped.png - res/tetrio_badges/tawshdsl_uncapped.png
- res/tetrio_badges/tawsignite_expert.png - res/tetrio_badges/tawsignite_expert.png
- res/tetrio_badges/tawslg.png - res/tetrio_badges/tawslg.png
@ -121,8 +130,16 @@ flutter:
- res/tetrio_badges/ttsdtc_1.png - res/tetrio_badges/ttsdtc_1.png
- res/tetrio_badges/ttsdtc_2.png - res/tetrio_badges/ttsdtc_2.png
- res/tetrio_badges/ttsdtc_3.png - res/tetrio_badges/ttsdtc_3.png
- res/tetrio_badges/ubcea_1.png
- res/tetrio_badges/ubcea_2.png
- res/tetrio_badges/ubcea_3.png
- res/tetrio_badges/underdog_1.png
- res/tetrio_badges/underdog_2.png
- res/tetrio_badges/underdog_3.png
- res/tetrio_badges/underdog_predict.png - res/tetrio_badges/underdog_predict.png
- res/tetrio_badges/wpl_1.png - res/tetrio_badges/wpl_1.png
- res/tetrio_badges/wpl_2.png
- res/tetrio_badges/wpl_3.png
- res/tetrio_badges/wplc_1.png - res/tetrio_badges/wplc_1.png
- res/tetrio_badges/wplc_2.png - res/tetrio_badges/wplc_2.png
- res/tetrio_badges/wplc_3.png - res/tetrio_badges/wplc_3.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

BIN
res/tetrio_badges/wpl_2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

BIN
res/tetrio_badges/wpl_3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB