TetraStats/lib/widgets/user_thingy.dart

355 lines
20 KiB
Dart

import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:syncfusion_flutter_gauges/gauges.dart';
import 'package:tetra_stats/data_objects/tetrio_constants.dart';
import 'package:tetra_stats/data_objects/tetrio_player.dart';
import 'package:tetra_stats/gen/strings.g.dart';
import 'package:tetra_stats/main.dart';
import 'package:tetra_stats/utils/copy_to_clipboard.dart';
import 'package:tetra_stats/utils/numers_formats.dart';
import 'package:tetra_stats/utils/relative_timestamps.dart';
import 'package:tetra_stats/utils/text_shadow.dart';
import 'package:tetra_stats/views/compare_view_tiles.dart';
import 'package:tetra_stats/widgets/text_timestamp.dart';
import 'package:transparent_image/transparent_image.dart';
class UserThingy extends StatefulWidget {
final TetrioPlayer player;
final bool showStateTimestamp;
final bool initIsTracking;
final Function setState;
const UserThingy({super.key, required this.player, required this.initIsTracking, required this.showStateTimestamp, required this.setState});
@override
State<UserThingy> createState() => _UserThingyState();
}
class _UserThingyState extends State<UserThingy> with SingleTickerProviderStateMixin {
late AnimationController _addToTrackAnimController;
late Animation _addToTrackAnim;
@override
void initState(){
_addToTrackAnimController = AnimationController(
value: widget.initIsTracking ? 1.0 : 0.0,
duration: Durations.extralong4,
vsync: this,
);
_addToTrackAnim = new Tween(
begin: 0.0,
end: 1.0,
).animate(new CurvedAnimation(
parent: _addToTrackAnimController,
curve: Cubic(.15,-0.40,.86,-0.39),
reverseCurve: Cubic(0,.99,.99,1.01)
));
super.initState();
}
@override
void dispose() {
_addToTrackAnimController.dispose();
super.dispose();
}
Color roleColor(String role){
switch (role){
case "sysop":
return const Color.fromARGB(255, 23, 165, 133);
case "admin":
return const Color.fromARGB(255, 255, 78, 138);
case "mod":
return const Color.fromARGB(255, 204, 128, 242);
case "halfmod":
return const Color.fromARGB(255, 95, 118, 254);
case "bot":
return const Color.fromARGB(255, 60, 93, 55);
case "banned":
return const Color.fromARGB(255, 248, 28, 28);
default:
return Colors.white10;
}
}
String fontStyle(int length){
if (length < 10) return "Eurostile Round Extended";
else if (length < 13) return "Eurostile Round";
else return "Eurostile Round Condensed";
}
@override
Widget build(BuildContext context) {
final t = Translations.of(context);
return LayoutBuilder(builder: (context, constraints) {
double pfpHeight = 128;
int xpTableID = 0;
while (widget.player.xp > xpTableScuffed.values.toList()[xpTableID]) {
xpTableID++;
}
return Card(
clipBehavior: Clip.antiAlias,
child: Column(
children: [
Padding(
padding: const EdgeInsets.only(bottom: 4.0),
child: Container(
constraints: const BoxConstraints(maxWidth: 960),
height: widget.player.bannerRevision != null ? 218.0 : 138.0,
child: Stack(
//clipBehavior: Clip.none,
children: [
// TODO: osk banner can cause memory leak
if (widget.player.bannerRevision != null) FadeInImage.memoryNetwork(image: kIsWeb ? "https://ts.dan63.by/oskware_bridge.php?endpoint=TetrioBanner&user=${widget.player.userId}&rv=${widget.player.bannerRevision}" : "https://tetr.io/user-content/banners/${widget.player.userId}.jpg?rv=${widget.player.bannerRevision}",
placeholder: kTransparentImage,
fit: BoxFit.cover,
height: 120,
fadeInCurve: Easing.standard, fadeInDuration: Durations.long4
),
Positioned(
top: widget.player.bannerRevision != null ? 90.0 : 10.0,
left: 16.0,
child: ClipRRect(
borderRadius: BorderRadius.circular(1000),
child: widget.player.role == "banned"
? Image.asset("res/avatars/tetrio_banned.png", fit: BoxFit.fitHeight, height: pfpHeight,)
: widget.player.avatarRevision != null
? FadeInImage.memoryNetwork(image: kIsWeb ? "https://ts.dan63.by/oskware_bridge.php?endpoint=TetrioProfilePicture&user=${widget.player.userId}&rv=${widget.player.avatarRevision}" : "https://tetr.io/user-content/avatars/${widget.player.userId}.jpg?rv=${widget.player.avatarRevision}",
fit: BoxFit.fitHeight, height: 128, placeholder: kTransparentImage, fadeInCurve: Easing.emphasizedDecelerate, fadeInDuration: Durations.long4)
: Image.asset("res/avatars/tetrio_anon.png", fit: BoxFit.fitHeight, height: pfpHeight),
)
),
Positioned(
top: widget.player.bannerRevision != null ? 120.0 : 40.0,
left: 160.0,
child: Tooltip(
message: "${widget.player.userId}\n(Click to copy user ID)",
child: RichText(text: TextSpan(text: widget.player.username, style: TextStyle(
fontFamily: fontStyle(widget.player.username.length),
fontSize: 28,
),
recognizer: TapGestureRecognizer()..onTap = (){
copyToClipboard(widget.player.userId);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(t.copiedToClipboard)));
}
)
)
),
),
Positioned(
top: widget.player.bannerRevision != null ? 160.0 : 80.0,
left: 160.0,
child: Row(
children: [
Padding(
padding: const EdgeInsets.only(right: 4.0),
child: Chip(label: Text(widget.player.role.toUpperCase(), style: const TextStyle(shadows: textShadow),), padding: const EdgeInsets.all(0.0), color: WidgetStatePropertyAll(roleColor(widget.player.role))),
),
RichText(
text: TextSpan(
style: const TextStyle(fontFamily: "Eurostile Round"),
children:
[
if (widget.player.friendCount > 0) const WidgetSpan(child: Icon(Icons.person), alignment: PlaceholderAlignment.middle, baseline: TextBaseline.alphabetic),
if (widget.player.friendCount > 0) TextSpan(text: "${intf.format(widget.player.friendCount)} "),
if (widget.player.supporterTier > 0) WidgetSpan(child: Icon(widget.player.supporterTier > 1 ? Icons.star : Icons.star_border, color: widget.player.supporterTier > 1 ? Colors.yellowAccent : Colors.white), alignment: PlaceholderAlignment.middle, baseline: TextBaseline.alphabetic),
if (widget.player.supporterTier > 0) TextSpan(text: widget.player.supporterTier.toString(), style: TextStyle(color: widget.player.supporterTier > 1 ? Colors.yellowAccent : Colors.white)),
]
)
)
],
),
),
Positioned(
top: widget.player.bannerRevision != null ? 193.0 : 113.0,
left: 160.0,
child: SizedBox(
width: 270,
child: RichText(
text: TextSpan(
style: const TextStyle(fontFamily: "Eurostile Round"),
children: [
TextSpan(text: timestamp(widget.player.registrationTime), style: const TextStyle(color: Colors.grey)),
if (widget.player.country != null) TextSpan(text: "${t.countries[widget.player.country]}")
]
)
),
)
),
Positioned(
top: widget.player.bannerRevision != null ? 126.0 : 46.0,
right: 16.0,
child: RichText(
textAlign: TextAlign.end,
text: TextSpan(
style: const TextStyle(fontFamily: "Eurostile Round"),
children: [
TextSpan(text: "Level ${(widget.player.level.isNegative || widget.player.level.isNaN) ? "---" : intf.format(widget.player.level.floor())}", style: TextStyle(decoration: (widget.player.level.isNegative || widget.player.level.isNaN) ? null : TextDecoration.underline, decorationColor: Colors.white70, decorationStyle: TextDecorationStyle.dotted, color: (widget.player.level.isNegative || widget.player.level.isNaN) ? Colors.grey : Colors.white), recognizer: (widget.player.level.isNegative || widget.player.level.isNaN) ? null : TapGestureRecognizer()?..onTap = (){
showDialog(
context: context,
builder: (BuildContext context) => AlertDialog(
title: Text("Level ${intf.format(widget.player.level.floor())}", textAlign: TextAlign.center),
content: SingleChildScrollView(
child: ListBody(children: [
Text(
"${NumberFormat.decimalPatternDigits(locale: LocaleSettings.currentLocale.languageCode, decimalDigits: 2).format(widget.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: widget.player.level - widget.player.level.floor(), color: Colors.cyanAccent),
LinearGaugeRange(startValue: 0, endValue: (widget.player.xp / xpTableScuffed.values.toList()[xpTableID]), color: Colors.redAccent, position: LinearElementPosition.cross)
],
showTicks: true,
showLabels: false
),
),
Text("${t.statCellNum.xpProgress}: ${((widget.player.level - widget.player.level.floor()) * 100).toStringAsFixed(2)} %"),
Text("${t.statCellNum.xpFrom0ToLevel(n: xpTableScuffed.keys.toList()[xpTableID])}: ${((widget.player.xp / xpTableScuffed.values.toList()[xpTableID]) * 100).toStringAsFixed(2)} % (${NumberFormat.decimalPatternDigits(locale: LocaleSettings.currentLocale.languageCode, decimalDigits: 0).format(xpTableScuffed.values.toList()[xpTableID] - widget.player.xp)} ${t.statCellNum.xpLeft})")
]
),
),
actions: <Widget>[
TextButton(
child: const Text("OK"),
onPressed: () {Navigator.of(context).pop();}
)
]
)
);
}),
const TextSpan(text:"\n"),
TextSpan(text: widget.player.gameTime.isNegative ? "-h --m" : playtime(widget.player.gameTime), style: TextStyle(color: widget.player.gameTime.isNegative ? Colors.grey : Colors.white, decoration: widget.player.gameTime.isNegative ? null : TextDecoration.underline, decorationColor: Colors.white70, decorationStyle: TextDecorationStyle.dotted), recognizer: !widget.player.gameTime.isNegative ? (TapGestureRecognizer()..onTap = (){
Duration accountAge = DateTime.timestamp().difference(widget.player.registrationTime);
Duration avgGametimeADay = Duration(microseconds: (widget.player.gameTime.inMicroseconds / accountAge.inDays).floor());
showDialog(
context: context,
builder: (BuildContext context) => AlertDialog(
title: Text(t.exactGametime, textAlign: TextAlign.center),
content: SingleChildScrollView(
child: Column(
children: [
RichText(text: TextSpan(
style: TextStyle(fontFamily: "Eurostile Round", color: Colors.white, fontSize: 28),
children: [
TextSpan(text: "${intf.format(widget.player.gameTime.inHours)}"),
TextSpan(text: ":${nonsecs.format(widget.player.gameTime.inMinutes%60)}:${nonsecs.format(widget.player.gameTime.inSeconds%60)}"),
TextSpan(text: ".${nonsecs3.format(widget.player.gameTime.inMicroseconds%1000000)}", style: TextStyle(fontSize: 14))
]
)),
Text("${playtime(avgGametimeADay)} a day in average"),
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text("It's ${f4.format(widget.player.gameTime.inSeconds/31536000)} years,"),
),
Text("or ${f4.format(widget.player.gameTime.inSeconds/2628000)} months,"),
Text("or ${f4.format(widget.player.gameTime.inSeconds/86400)} days,"),
Text("or ${f2.format(widget.player.gameTime.inMilliseconds/60000)} minutes,"),
Text("or ${intf.format(widget.player.gameTime.inSeconds)} seconds"),
]
),
),
actions: <Widget>[
TextButton(
child: const Text("OK"),
onPressed: () {Navigator.of(context).pop();}
)
]
)
);
}) : null),
const TextSpan(text:"\n"),
TextSpan(text: widget.player.gamesWon > -1 ? intf.format(widget.player.gamesWon) : "---", style: TextStyle(color: widget.player.gamesWon > -1 ? Colors.white : Colors.grey)),
TextSpan(text: "/${widget.player.gamesPlayed > -1 ? intf.format(widget.player.gamesPlayed) : "---"}", style: const TextStyle(fontFamily: "Eurostile Round Condensed", color: Colors.grey)),
]
)
)
)
],
),
),
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Expanded(
child: AnimatedBuilder(
animation: _addToTrackAnim,
builder: (context, child) {
double firstButtonPosition = 0+(_addToTrackAnim.value as double)*25;
double secondButtonPosition = -25+(_addToTrackAnim.value as double)*25;
double firstButtonOpacity = 1-(_addToTrackAnim.value as double)*2;
double secondButtonOpacity = _addToTrackAnim.value*2-1;
return ElevatedButton.icon(
onPressed: (){
_addToTrackAnimController.value == 1 ? teto.deletePlayerToTrack(widget.player.userId) : teto.addPlayerToTrack(widget.player);
_addToTrackAnim.isCompleted ? _addToTrackAnimController.reverse() : _addToTrackAnimController.forward();
},
icon: _addToTrackAnim.value < 0.5 ? Opacity(
opacity: min(1, firstButtonOpacity),
child: Transform.translate(
offset: Offset(0, _addToTrackAnim.status == AnimationStatus.forward ? firstButtonPosition*4 : firstButtonPosition),
child: Transform.rotate(
angle:_addToTrackAnim.status == AnimationStatus.forward ? (_addToTrackAnim.value as double)*2 : 0,
child: const Icon(Icons.person_add),
),
),
) : Container(
transform: Matrix4.translationValues(secondButtonPosition*5, -secondButtonPosition*25, 0),
child: Opacity(
opacity: max(0, min(1, secondButtonOpacity)),
child: Transform.rotate(
angle:_addToTrackAnim.status == AnimationStatus.reverse ? (1-_addToTrackAnim.value as double)*-20 : 0,
child: const Icon(Icons.person_remove)
)
)
),
label: _addToTrackAnim.value < 0.5 ? Container(
transform: Matrix4.translationValues(0, firstButtonPosition, 0),
child: Opacity(
opacity: max(min(1, firstButtonOpacity), 0),
child: Text(_addToTrackAnimController.isAnimating && _addToTrackAnim.status == AnimationStatus.forward ? "Done!" : "Track")
)
) : Container(
transform: Matrix4.translationValues(0, secondButtonPosition, 0),
child: Opacity(
opacity: max(0, min(1, secondButtonOpacity)),
child: Text(_addToTrackAnimController.isAnimating && _addToTrackAnim.status == AnimationStatus.reverse ? "Done! " : "Stop tracking")
)
),
style: const ButtonStyle(shape: WidgetStatePropertyAll(RoundedRectangleBorder(borderRadius: BorderRadius.only(bottomLeft: Radius.circular(12.0))))));
},
)),
Expanded(
child: ElevatedButton.icon(
onPressed: (){
Navigator.push(context, MaterialPageRoute(
builder: (context) => CompareView(widget.player),
),
);
},
icon: const Icon(Icons.balance),
label: Text(t.compare),
style: const ButtonStyle(shape: WidgetStatePropertyAll(RoundedRectangleBorder(borderRadius: BorderRadius.only(bottomRight: Radius.circular(12.0)))))
)
)
],
)
],
),
);
});
}
}