2353-add-number-animation-xp-earned-in-it-bar (#2363)
* fix(IT Controller): fixed accuracy issues with star calcultion and point calculation for vocab + grammar. Added number animation. Staggered animations * generated * chore: redirect to new group page on click new chat button in space view (#2354) * chore: disable custom message text sizing (#2355) * feat: initial work to prevent giving points for copy-pasted text (#2345) * feat: initial work to prevent giving points for copy-pasted text * chore: replace tokenization with comparing token content with pasted content * fix(emoji_activity_generator): ensure unique choices * fix(intl_en): two copy edits * fix(lemma_meaning_widget): fix text alignment * chore(practice_selection): preferencing tokens without activities in selection * 2364 on chat creation with activity if no room image set activity image (#2371) * chore: formatting * chore: on chat creation without activity, set avatar to activity image if no image set * chore: in empty chat popup, listen for changes to participant count and close self if it increases (#2372) * chore: constrain width of unsubscribed card (#2373) * chore: fix dialogs in report offensive message flow (#2380) * chore: fix off-center close button in level up notifications (#2382) * chore: fix discrepency between original message and centered message border radius (#2383) * chore: don't show presence indicator on small avatars (#2386) * chore: give max height to image in activity suggestion dialog (#2403) * chore: when navigating to space details, always open space view (#2405) * chore: fix vertical alignment of tokens in HTML-formatted messages (#2406) * added robot animation and message to instruct user to wait after too … (#2415) * added robot animation and message to instruct user to wait after too many join with code attempts * chore: formatting * replaced hardcoded text with intl_en.arb * Resolving missing import * generated * chore: formatting --------- Co-authored-by: ggurdin <ggurdin@gmail.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * refactor: separate token and message reading assistance modes (#2416) * refactor: separate token and message reading assistance modes * chore: apply same token styling to HTML formatted messages * chore: don't wait for lemma responses before showing reading assistance content * 2421 reading assistance mode split feedback from will (#2422) * chore: make input bar shorter in token mode * chore: retry showing reading assistance content for initial token * chore: make background lighter in token mode * Added 'JoinByCode' button on new group view (#2417) * Added 'JoinByCode' button on new group view * generated --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: ggurdin <46800240+ggurdin@users.noreply.github.com> * fix(IT): added chreo code back + added original feedback star class back * generated * chore: revert change to it controller, use choreo record to determine which constructs are new --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: ggurdin <46800240+ggurdin@users.noreply.github.com> Co-authored-by: wcjord <32568597+wcjord@users.noreply.github.com> Co-authored-by: Sofanyas Genene <123987957+Sofanyas@users.noreply.github.com> Co-authored-by: ggurdin <ggurdin@gmail.com>
This commit is contained in:
parent
d111b11783
commit
ddbc215252
3 changed files with 424 additions and 76 deletions
|
|
@ -3,11 +3,15 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter_gen/gen_l10n/l10n.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
|
||||
import 'package:fluffychat/pangea/analytics_misc/construct_use_type_enum.dart';
|
||||
import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart';
|
||||
import 'package:fluffychat/pangea/choreographer/constants/choreo_constants.dart';
|
||||
import 'package:fluffychat/pangea/choreographer/controllers/choreographer.dart';
|
||||
import 'package:fluffychat/pangea/choreographer/controllers/error_service.dart';
|
||||
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
|
||||
import 'package:fluffychat/pangea/events/models/pangea_token_model.dart';
|
||||
import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
|
||||
import 'package:fluffychat/widgets/matrix.dart';
|
||||
import '../repo/similarity_repo.dart';
|
||||
|
||||
class AlternativeTranslator {
|
||||
|
|
@ -19,6 +23,7 @@ class AlternativeTranslator {
|
|||
FeedbackKey? translationFeedbackKey;
|
||||
List<String> translations = [];
|
||||
SimilartyResponseModel? similarityResponse;
|
||||
|
||||
AlternativeTranslator(this.choreographer);
|
||||
|
||||
void clear() {
|
||||
|
|
@ -97,28 +102,86 @@ class AlternativeTranslator {
|
|||
}
|
||||
}
|
||||
|
||||
List<PangeaToken> get _selectedTokens => choreographer.choreoRecord.itSteps
|
||||
.where((step) => step.chosenContinuance != null)
|
||||
.map(
|
||||
(step) => step.chosenContinuance!.tokens.where(
|
||||
(token) => token.lemma.saveVocab,
|
||||
),
|
||||
)
|
||||
.expand((element) => element)
|
||||
.toList();
|
||||
List<OneConstructUse> get _itStepConstructs {
|
||||
final metadata = ConstructUseMetaData(
|
||||
roomId: choreographer.roomId,
|
||||
timeStamp: DateTime.now(),
|
||||
);
|
||||
|
||||
int countVocabularyWordsFromSteps() =>
|
||||
_selectedTokens.map((t) => t.lemma.text.toLowerCase()).toSet().length;
|
||||
final List<OneConstructUse> constructs = [];
|
||||
for (final step in choreographer.choreoRecord.itSteps) {
|
||||
for (final continuance in step.continuances) {
|
||||
final ConstructUseTypeEnum useType = continuance.wasClicked &&
|
||||
continuance.level == ChoreoConstants.levelThresholdForGreen
|
||||
? ConstructUseTypeEnum.corIt
|
||||
: continuance.wasClicked
|
||||
? ConstructUseTypeEnum.incIt
|
||||
: ConstructUseTypeEnum.ignIt;
|
||||
|
||||
int countGrammarConstructsFromSteps() => _selectedTokens
|
||||
.map(
|
||||
(t) => t.morph.entries.map(
|
||||
(m) => "${m.key}:${m.value}".toLowerCase(),
|
||||
),
|
||||
)
|
||||
.expand((m) => m)
|
||||
.toSet()
|
||||
.length;
|
||||
final tokens = continuance.tokens.where((t) => t.lemma.saveVocab);
|
||||
constructs.addAll(
|
||||
tokens.map(
|
||||
(token) => OneConstructUse(
|
||||
useType: useType,
|
||||
lemma: token.lemma.text,
|
||||
constructType: ConstructTypeEnum.vocab,
|
||||
metadata: metadata,
|
||||
category: token.pos,
|
||||
form: token.text.content,
|
||||
),
|
||||
),
|
||||
);
|
||||
for (final token in tokens) {
|
||||
constructs.add(
|
||||
OneConstructUse(
|
||||
useType: useType,
|
||||
lemma: token.pos,
|
||||
form: token.text.content,
|
||||
category: "POS",
|
||||
constructType: ConstructTypeEnum.morph,
|
||||
metadata: metadata,
|
||||
),
|
||||
);
|
||||
for (final entry in token.morph.entries) {
|
||||
constructs.add(
|
||||
OneConstructUse(
|
||||
useType: useType,
|
||||
lemma: entry.value,
|
||||
form: token.text.content,
|
||||
category: entry.key,
|
||||
constructType: ConstructTypeEnum.morph,
|
||||
metadata: metadata,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return constructs;
|
||||
}
|
||||
|
||||
int countNewConstructs(ConstructTypeEnum type) {
|
||||
final vocabUses = _itStepConstructs.where((c) => c.constructType == type);
|
||||
final Map<ConstructIdentifier, int> constructPoints = {};
|
||||
for (final use in vocabUses) {
|
||||
constructPoints[use.identifier] ??= 0;
|
||||
constructPoints[use.identifier] =
|
||||
constructPoints[use.identifier]! + use.pointValue;
|
||||
}
|
||||
|
||||
final constructListModel =
|
||||
MatrixState.pangeaController.getAnalytics.constructListModel;
|
||||
|
||||
int newConstructCount = 0;
|
||||
for (final entry in constructPoints.entries) {
|
||||
final construct = constructListModel.getConstructUses(entry.key);
|
||||
if (construct?.points == entry.value) {
|
||||
newConstructCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return newConstructCount;
|
||||
}
|
||||
|
||||
String getDefaultFeedback(BuildContext context) {
|
||||
final l10n = L10n.of(context);
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import 'dart:developer';
|
|||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
|
||||
import 'package:fluffychat/pangea/analytics_misc/construct_use_type_enum.dart';
|
||||
import 'package:fluffychat/pangea/choreographer/constants/choreo_constants.dart';
|
||||
import 'package:fluffychat/pangea/choreographer/controllers/choreographer.dart';
|
||||
|
|
@ -232,10 +233,14 @@ class ITBarState extends State<ITBar> with SingleTickerProviderStateMixin {
|
|||
controller: itController,
|
||||
vocabCount: itController
|
||||
.choreographer.altTranslator
|
||||
.countVocabularyWordsFromSteps(),
|
||||
.countNewConstructs(
|
||||
ConstructTypeEnum.vocab,
|
||||
),
|
||||
grammarCount: itController
|
||||
.choreographer.altTranslator
|
||||
.countGrammarConstructsFromSteps(),
|
||||
.countNewConstructs(
|
||||
ConstructTypeEnum.morph,
|
||||
),
|
||||
feedbackText: itController
|
||||
.choreographer.altTranslator
|
||||
.getDefaultFeedback(context),
|
||||
|
|
|
|||
|
|
@ -9,12 +9,12 @@ import '../../bot/utils/bot_style.dart';
|
|||
import '../../common/utils/error_handler.dart';
|
||||
import '../controllers/it_controller.dart';
|
||||
|
||||
class TranslationFeedback extends StatelessWidget {
|
||||
class TranslationFeedback extends StatefulWidget {
|
||||
final int vocabCount;
|
||||
final int grammarCount;
|
||||
final String feedbackText;
|
||||
|
||||
final ITController controller;
|
||||
|
||||
const TranslationFeedback({
|
||||
super.key,
|
||||
required this.controller,
|
||||
|
|
@ -23,65 +23,231 @@ class TranslationFeedback extends StatelessWidget {
|
|||
required this.feedbackText,
|
||||
});
|
||||
|
||||
@override
|
||||
State<TranslationFeedback> createState() => _TranslationFeedbackState();
|
||||
}
|
||||
|
||||
class _TranslationFeedbackState extends State<TranslationFeedback>
|
||||
with TickerProviderStateMixin {
|
||||
late final int starRating;
|
||||
late final int vocabCount;
|
||||
late final int grammarCount;
|
||||
|
||||
// Animation controllers for each component
|
||||
late AnimationController _starsController;
|
||||
late AnimationController _vocabController;
|
||||
late AnimationController _grammarController;
|
||||
|
||||
// Animations for opacity and scale
|
||||
late Animation<double> _starsOpacity;
|
||||
late Animation<double> _starsScale;
|
||||
late Animation<double> _vocabOpacity;
|
||||
late Animation<double> _grammarOpacity;
|
||||
|
||||
// Constants for animation timing
|
||||
static const vocabDelay = Duration(milliseconds: 800);
|
||||
static const grammarDelay = Duration(milliseconds: 1400);
|
||||
|
||||
// Duration for each individual animation
|
||||
static const elementAnimDuration = Duration(milliseconds: 800);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
vocabCount = widget.vocabCount;
|
||||
grammarCount = widget.grammarCount;
|
||||
|
||||
final altTranslator = widget.controller.choreographer.altTranslator;
|
||||
starRating = altTranslator.starRating;
|
||||
|
||||
// Initialize animation controllers
|
||||
_starsController = AnimationController(
|
||||
vsync: this,
|
||||
duration: elementAnimDuration,
|
||||
);
|
||||
|
||||
_vocabController = AnimationController(
|
||||
vsync: this,
|
||||
duration: elementAnimDuration,
|
||||
);
|
||||
|
||||
_grammarController = AnimationController(
|
||||
vsync: this,
|
||||
duration: elementAnimDuration,
|
||||
);
|
||||
|
||||
// Define animations
|
||||
_starsOpacity = Tween<double>(begin: 0.0, end: 1.0).animate(
|
||||
CurvedAnimation(parent: _starsController, curve: Curves.easeInOut),
|
||||
);
|
||||
|
||||
_starsScale = Tween<double>(begin: 0.5, end: 1.0).animate(
|
||||
CurvedAnimation(parent: _starsController, curve: Curves.elasticOut),
|
||||
);
|
||||
|
||||
_vocabOpacity = Tween<double>(begin: 0.0, end: 1.0).animate(
|
||||
CurvedAnimation(parent: _vocabController, curve: Curves.easeInOut),
|
||||
);
|
||||
|
||||
_grammarOpacity = Tween<double>(begin: 0.0, end: 1.0).animate(
|
||||
CurvedAnimation(parent: _grammarController, curve: Curves.easeInOut),
|
||||
);
|
||||
|
||||
// Start animations with appropriate delays
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
// Start stars animation immediately
|
||||
_starsController.forward();
|
||||
|
||||
// Start vocab animation after delay if there's vocab to show
|
||||
if (vocabCount > 0) {
|
||||
Future.delayed(vocabDelay, () {
|
||||
if (mounted) _vocabController.forward();
|
||||
});
|
||||
}
|
||||
|
||||
// Start grammar animation after delay if there's grammar to show
|
||||
if (grammarCount > 0) {
|
||||
Future.delayed(grammarDelay, () {
|
||||
if (mounted) _grammarController.forward();
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_starsController.dispose();
|
||||
_vocabController.dispose();
|
||||
_grammarController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final altTranslator = controller.choreographer.altTranslator;
|
||||
try {
|
||||
return Column(
|
||||
spacing: 16.0,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
FillingStars(rating: altTranslator.starRating),
|
||||
// Animated stars
|
||||
AnimatedBuilder(
|
||||
animation: _starsController,
|
||||
builder: (context, child) {
|
||||
return Opacity(
|
||||
opacity: _starsOpacity.value,
|
||||
child: Transform.scale(
|
||||
scale: _starsScale.value,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16.0),
|
||||
child: FillingStars(rating: starRating),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
if (vocabCount > 0 || grammarCount > 0)
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
spacing: 16.0,
|
||||
children: [
|
||||
if (vocabCount > 0)
|
||||
Row(
|
||||
spacing: 8.0,
|
||||
children: [
|
||||
Icon(
|
||||
Symbols.dictionary,
|
||||
color: ProgressIndicatorEnum.wordsUsed.color(context),
|
||||
size: 24,
|
||||
),
|
||||
Text(
|
||||
"+ $vocabCount",
|
||||
style: TextStyle(
|
||||
color: ProgressIndicatorEnum.wordsUsed.color(context),
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (grammarCount > 0)
|
||||
Row(
|
||||
spacing: 8.0,
|
||||
children: [
|
||||
Icon(
|
||||
Symbols.toys_and_games,
|
||||
color: ProgressIndicatorEnum.morphsUsed.color(context),
|
||||
size: 24,
|
||||
),
|
||||
Text(
|
||||
"+ $grammarCount",
|
||||
style: TextStyle(
|
||||
color:
|
||||
ProgressIndicatorEnum.morphsUsed.color(context),
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (vocabCount > 0)
|
||||
AnimatedBuilder(
|
||||
animation: _vocabController,
|
||||
builder: (context, child) {
|
||||
return Opacity(
|
||||
opacity: _vocabOpacity.value,
|
||||
child: Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Symbols.dictionary,
|
||||
color: ProgressIndicatorEnum.wordsUsed
|
||||
.color(context),
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(width: 4.0),
|
||||
AnimatedCounter(
|
||||
key: const ValueKey("vocabCounter"),
|
||||
endValue: vocabCount,
|
||||
// Only start counter animation when opacity animation is complete
|
||||
startAnimation: _vocabOpacity.value > 0.9,
|
||||
style: TextStyle(
|
||||
color: ProgressIndicatorEnum.wordsUsed
|
||||
.color(context),
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
if (grammarCount > 0)
|
||||
AnimatedBuilder(
|
||||
animation: _grammarController,
|
||||
builder: (context, child) {
|
||||
return Opacity(
|
||||
opacity: _grammarOpacity.value,
|
||||
child: Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Symbols.toys_and_games,
|
||||
color: ProgressIndicatorEnum.morphsUsed
|
||||
.color(context),
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(width: 4.0),
|
||||
AnimatedCounter(
|
||||
key: const ValueKey("grammarCounter"),
|
||||
endValue: grammarCount,
|
||||
// Only start counter animation when opacity animation is complete
|
||||
startAnimation: _grammarOpacity.value > 0.9,
|
||||
style: TextStyle(
|
||||
color: ProgressIndicatorEnum.morphsUsed
|
||||
.color(context),
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else
|
||||
Text(
|
||||
feedbackText,
|
||||
textAlign: TextAlign.center,
|
||||
style: BotStyle.text(context),
|
||||
AnimatedBuilder(
|
||||
animation: _starsController,
|
||||
builder: (context, child) {
|
||||
return Opacity(
|
||||
opacity: _starsOpacity.value,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: Text(
|
||||
widget.feedbackText,
|
||||
textAlign: TextAlign.center,
|
||||
style: BotStyle.text(context),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16.0),
|
||||
|
||||
const SizedBox(height: 8.0),
|
||||
],
|
||||
);
|
||||
} catch (err, stack) {
|
||||
|
|
@ -91,9 +257,123 @@ class TranslationFeedback extends StatelessWidget {
|
|||
s: stack,
|
||||
data: {},
|
||||
);
|
||||
|
||||
// Fallback to a simple message if anything goes wrong
|
||||
return Center(child: Text(L10n.of(context).niceJob));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class AnimatedCounter extends StatefulWidget {
|
||||
final int endValue;
|
||||
final TextStyle? style;
|
||||
final Duration duration;
|
||||
final String prefix;
|
||||
final bool startAnimation;
|
||||
|
||||
const AnimatedCounter({
|
||||
super.key,
|
||||
required this.endValue,
|
||||
this.style,
|
||||
this.duration = const Duration(milliseconds: 1500),
|
||||
this.prefix = "+ ",
|
||||
this.startAnimation = true,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AnimatedCounter> createState() => _AnimatedCounterState();
|
||||
}
|
||||
|
||||
class _AnimatedCounterState extends State<AnimatedCounter>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _controller;
|
||||
late Animation<int> _animation;
|
||||
bool _hasAnimated = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: widget.duration,
|
||||
);
|
||||
|
||||
_animation = IntTween(
|
||||
begin: 0,
|
||||
end: widget.endValue,
|
||||
).animate(
|
||||
CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: Curves.easeOutCubic,
|
||||
),
|
||||
);
|
||||
|
||||
// Only start animation if startAnimation is true
|
||||
if (widget.startAnimation) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
_controller.forward();
|
||||
_hasAnimated = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(AnimatedCounter oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
|
||||
// Start animation when startAnimation changes to true
|
||||
if (!oldWidget.startAnimation && widget.startAnimation && !_hasAnimated) {
|
||||
_controller.forward();
|
||||
_hasAnimated = true;
|
||||
}
|
||||
|
||||
if (oldWidget.endValue != widget.endValue) {
|
||||
if (_hasAnimated) {
|
||||
_animation = IntTween(
|
||||
begin: _animation.value,
|
||||
end: widget.endValue,
|
||||
).animate(
|
||||
CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: Curves.easeOutCubic,
|
||||
),
|
||||
);
|
||||
|
||||
_controller.forward(from: 0.0);
|
||||
} else if (widget.startAnimation) {
|
||||
_animation = IntTween(
|
||||
begin: 0,
|
||||
end: widget.endValue,
|
||||
).animate(
|
||||
CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: Curves.easeOutCubic,
|
||||
),
|
||||
);
|
||||
|
||||
_controller.forward();
|
||||
_hasAnimated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: _animation,
|
||||
builder: (context, child) {
|
||||
return Text(
|
||||
"${widget.prefix}${_animation.value}",
|
||||
style: widget.style,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue