1719 grammar detailed view in analytics (#1728)
* feat: grammar analytics details page --------- Co-authored-by: wcjord <32568597+wcjord@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>pull/1605/head
parent
8988cce68a
commit
a71f519700
@ -0,0 +1,100 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:flutter_gen/gen_l10n/l10n.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
import 'package:fluffychat/config/app_config.dart';
|
||||
import 'package:fluffychat/pangea/analytics_details_popup/lemma_usage_dots.dart';
|
||||
import 'package:fluffychat/pangea/analytics_details_popup/lemma_use_example_messages.dart';
|
||||
import 'package:fluffychat/pangea/analytics_misc/analytics_constants.dart';
|
||||
import 'package:fluffychat/pangea/analytics_misc/construct_identifier.dart';
|
||||
import 'package:fluffychat/pangea/analytics_misc/construct_level_enum.dart';
|
||||
import 'package:fluffychat/pangea/analytics_misc/construct_use_model.dart';
|
||||
import 'package:fluffychat/pangea/analytics_misc/learning_skills_enum.dart';
|
||||
|
||||
class AnalyticsDetailsViewContent extends StatelessWidget {
|
||||
final Widget title;
|
||||
final Widget subtitle;
|
||||
final Widget headerContent;
|
||||
final Widget xpIcon;
|
||||
final ConstructIdentifier constructId;
|
||||
|
||||
const AnalyticsDetailsViewContent({
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.xpIcon,
|
||||
required this.headerContent,
|
||||
required this.constructId,
|
||||
super.key,
|
||||
});
|
||||
|
||||
ConstructUses get construct => constructId.constructUses;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final Color textColor = Theme.of(context).brightness != Brightness.light
|
||||
? construct.lemmaCategory.color
|
||||
: construct.lemmaCategory.darkColor;
|
||||
|
||||
return SingleChildScrollView(
|
||||
child: Column(
|
||||
children: [
|
||||
title,
|
||||
const SizedBox(height: 16.0),
|
||||
subtitle,
|
||||
const SizedBox(height: 16.0),
|
||||
headerContent,
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16.0),
|
||||
child: Image.network(
|
||||
"${AppConfig.assetsBaseURL}/${AnalyticsConstants.popupDividerFileName}",
|
||||
),
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
xpIcon,
|
||||
const SizedBox(width: 16.0),
|
||||
Text(
|
||||
"${construct.points} XP",
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: textColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(20.0),
|
||||
child: Column(
|
||||
children: [
|
||||
LemmaUseExampleMessages(construct: construct),
|
||||
// Writing exercise section
|
||||
LemmaUsageDots(
|
||||
construct: construct,
|
||||
category: LearningSkillsEnum.writing,
|
||||
tooltip: L10n.of(context).writingExercisesTooltip,
|
||||
icon: Symbols.edit_square,
|
||||
),
|
||||
// Listening exercise section
|
||||
LemmaUsageDots(
|
||||
construct: construct,
|
||||
category: LearningSkillsEnum.hearing,
|
||||
tooltip: L10n.of(context).listeningExercisesTooltip,
|
||||
icon: Symbols.hearing,
|
||||
),
|
||||
// Reading exercise section
|
||||
LemmaUsageDots(
|
||||
construct: construct,
|
||||
category: LearningSkillsEnum.reading,
|
||||
tooltip: L10n.of(context).readingExercisesTooltip,
|
||||
icon: Symbols.two_pager,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,174 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:flutter_gen/gen_l10n/l10n.dart';
|
||||
|
||||
import 'package:fluffychat/pangea/analytics_details_popup/analytics_details_popup_content.dart';
|
||||
import 'package:fluffychat/pangea/analytics_misc/construct_identifier.dart';
|
||||
import 'package:fluffychat/pangea/analytics_misc/construct_level_enum.dart';
|
||||
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
|
||||
import 'package:fluffychat/pangea/analytics_misc/construct_use_model.dart';
|
||||
import 'package:fluffychat/pangea/analytics_misc/text_loading_shimmer.dart';
|
||||
import 'package:fluffychat/pangea/morphs/get_grammar_copy.dart';
|
||||
import 'package:fluffychat/pangea/morphs/morph_icon.dart';
|
||||
import 'package:fluffychat/pangea/morphs/morph_meaning/morph_info_repo.dart';
|
||||
|
||||
class MorphDetailsView extends StatelessWidget {
|
||||
final ConstructIdentifier constructId;
|
||||
|
||||
const MorphDetailsView({
|
||||
required this.constructId,
|
||||
super.key,
|
||||
});
|
||||
|
||||
ConstructUses get _construct => constructId.constructUses;
|
||||
String get _morphFeature => constructId.category;
|
||||
String get _morphTag => constructId.lemma;
|
||||
|
||||
String _categoryCopy(
|
||||
BuildContext context,
|
||||
) {
|
||||
if (_morphFeature.toLowerCase() == "other") {
|
||||
return L10n.of(context).other;
|
||||
}
|
||||
|
||||
return ConstructTypeEnum.morph.getDisplayCopy(
|
||||
_morphFeature,
|
||||
context,
|
||||
) ??
|
||||
_morphFeature;
|
||||
}
|
||||
|
||||
Future<String> _getDefinition(BuildContext context) => MorphInfoRepo.get(
|
||||
feature: _construct.category,
|
||||
tag: _construct.lemma,
|
||||
).then((value) => value ?? L10n.of(context).meaningNotFound);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final Color textColor = Theme.of(context).brightness != Brightness.light
|
||||
? _construct.lemmaCategory.color
|
||||
: _construct.lemmaCategory.darkColor;
|
||||
|
||||
return AnalyticsDetailsViewContent(
|
||||
title: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 32.0,
|
||||
height: 32.0,
|
||||
child: MorphIcon(
|
||||
morphFeature: _morphFeature,
|
||||
morphTag: _morphTag,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10.0),
|
||||
Text(
|
||||
getGrammarCopy(
|
||||
category: _morphFeature,
|
||||
lemma: _morphTag,
|
||||
context: context,
|
||||
) ??
|
||||
_morphTag,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
],
|
||||
),
|
||||
subtitle: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 24.0,
|
||||
height: 24.0,
|
||||
child: MorphIcon(morphFeature: _morphFeature, morphTag: null),
|
||||
),
|
||||
const SizedBox(width: 10.0),
|
||||
Text(
|
||||
_categoryCopy(context),
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: textColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
headerContent: Padding(
|
||||
padding: const EdgeInsets.all(25.0),
|
||||
child: Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: FutureBuilder(
|
||||
future: _getDefinition(context),
|
||||
builder: (
|
||||
BuildContext context,
|
||||
AsyncSnapshot<String?> snapshot,
|
||||
) {
|
||||
if (snapshot.hasData) {
|
||||
return RichText(
|
||||
text: TextSpan(
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
children: <TextSpan>[
|
||||
TextSpan(
|
||||
text: L10n.of(context).meaningSectionHeader,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
TextSpan(
|
||||
text: " ${snapshot.data!}",
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
} else if (snapshot.hasError) {
|
||||
return Wrap(
|
||||
children: [
|
||||
Text(
|
||||
L10n.of(context).meaningSectionHeader,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(
|
||||
width: 10,
|
||||
),
|
||||
Text(
|
||||
L10n.of(context).meaningNotFound,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
],
|
||||
);
|
||||
} else {
|
||||
return Wrap(
|
||||
children: [
|
||||
Text(
|
||||
L10n.of(context).meaningSectionHeader,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(
|
||||
width: 10,
|
||||
),
|
||||
const TextLoadingShimmer(width: 100),
|
||||
],
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
xpIcon: CircleAvatar(
|
||||
radius: 16.0,
|
||||
backgroundColor: _construct.lemmaCategory.color,
|
||||
child: const Icon(
|
||||
Icons.star,
|
||||
color: Colors.white,
|
||||
size: 20.0,
|
||||
),
|
||||
),
|
||||
constructId: constructId,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,99 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'package:get_storage/get_storage.dart';
|
||||
import 'package:http/http.dart';
|
||||
|
||||
import 'package:fluffychat/pangea/common/config/environment.dart';
|
||||
import 'package:fluffychat/pangea/common/network/requests.dart';
|
||||
import 'package:fluffychat/pangea/common/network/urls.dart';
|
||||
import 'package:fluffychat/pangea/learning_settings/constants/language_constants.dart';
|
||||
import 'package:fluffychat/pangea/morphs/morph_meaning/morph_info_request.dart';
|
||||
import 'package:fluffychat/pangea/morphs/morph_meaning/morph_info_response.dart';
|
||||
import 'package:fluffychat/widgets/matrix.dart';
|
||||
|
||||
class _APICallCacheItem {
|
||||
final DateTime time;
|
||||
final Future<MorphInfoResponse> future;
|
||||
|
||||
_APICallCacheItem(this.time, this.future);
|
||||
}
|
||||
|
||||
class MorphInfoRepo {
|
||||
static final GetStorage _morphMeaningStorage =
|
||||
GetStorage('morph_meaning_storage');
|
||||
static final shortTermCache = <String, _APICallCacheItem>{};
|
||||
static const int _cacheDurationMinutes = 1;
|
||||
|
||||
static void set(MorphInfoRequest request, MorphInfoResponse response) {
|
||||
_morphMeaningStorage.write(request.storageKey, response.toJson());
|
||||
}
|
||||
|
||||
static Future<MorphInfoResponse> _fetch(MorphInfoRequest request) async {
|
||||
try {
|
||||
final Requests req = Requests(
|
||||
choreoApiKey: Environment.choreoApiKey,
|
||||
accessToken: MatrixState.pangeaController.userController.accessToken,
|
||||
);
|
||||
|
||||
final Response res = await req.post(
|
||||
url: PApiUrls.morphDictionary,
|
||||
body: request.toJson(),
|
||||
);
|
||||
|
||||
final decodedBody = jsonDecode(utf8.decode(res.bodyBytes));
|
||||
final response = MorphInfoResponse.fromJson(decodedBody);
|
||||
|
||||
set(request, response);
|
||||
|
||||
return response;
|
||||
} catch (e, s) {
|
||||
debugPrint('Error fetching morph info: $e');
|
||||
return Future.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
static Future<MorphInfoResponse> _get(MorphInfoRequest request) async {
|
||||
request.userL1 == request.userL1.split('-').first;
|
||||
request.userL2 == request.userL2.split('-').first;
|
||||
|
||||
final cachedJson = _morphMeaningStorage.read(request.storageKey);
|
||||
if (cachedJson != null) {
|
||||
return MorphInfoResponse.fromJson(cachedJson);
|
||||
}
|
||||
|
||||
final _APICallCacheItem? cachedCall = shortTermCache[request.storageKey];
|
||||
if (cachedCall != null) {
|
||||
if (DateTime.now().difference(cachedCall.time).inMinutes <
|
||||
_cacheDurationMinutes) {
|
||||
return cachedCall.future;
|
||||
} else {
|
||||
shortTermCache.remove(request.storageKey);
|
||||
}
|
||||
}
|
||||
|
||||
final future = _fetch(request);
|
||||
shortTermCache[request.storageKey] =
|
||||
_APICallCacheItem(DateTime.now(), future);
|
||||
return future;
|
||||
}
|
||||
|
||||
static Future<String?> get({
|
||||
required String feature,
|
||||
required String tag,
|
||||
}) async {
|
||||
final res = await _get(
|
||||
MorphInfoRequest(
|
||||
userL1:
|
||||
MatrixState.pangeaController.languageController.userL1?.langCode ??
|
||||
LanguageKeys.defaultLanguage,
|
||||
userL2:
|
||||
MatrixState.pangeaController.languageController.userL2?.langCode ??
|
||||
LanguageKeys.defaultLanguage,
|
||||
),
|
||||
);
|
||||
|
||||
return res.getFeatureByCode(feature)?.getTagByCode(tag)?.l1Description;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,30 @@
|
||||
class MorphInfoRequest {
|
||||
final String userL1;
|
||||
final String userL2;
|
||||
|
||||
MorphInfoRequest({
|
||||
required this.userL1,
|
||||
required this.userL2,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'user_l1': userL1,
|
||||
'user_l2': userL2,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is MorphInfoRequest &&
|
||||
userL1 == other.userL1 &&
|
||||
userL2 == other.userL2;
|
||||
|
||||
@override
|
||||
int get hashCode => userL1.hashCode ^ userL2.hashCode;
|
||||
|
||||
String get storageKey {
|
||||
return userL1 + userL2;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,112 @@
|
||||
import 'package:collection/collection.dart';
|
||||
|
||||
class MorphologicalTag {
|
||||
final String code;
|
||||
final String l2Title;
|
||||
final String l1Title;
|
||||
final String l1Description;
|
||||
|
||||
MorphologicalTag({
|
||||
required this.code,
|
||||
required this.l2Title,
|
||||
required this.l1Title,
|
||||
required this.l1Description,
|
||||
});
|
||||
|
||||
factory MorphologicalTag.fromJson(Map<String, dynamic> json) {
|
||||
return MorphologicalTag(
|
||||
code: json['code'],
|
||||
l2Title: json['l2_title'],
|
||||
l1Title: json['l1_title'],
|
||||
l1Description: json['l1_description'],
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'code': code,
|
||||
'l2_title': l2Title,
|
||||
'l1_title': l1Title,
|
||||
'l1_description': l1Description,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class MorphologicalFeature {
|
||||
final String code;
|
||||
final String l2Title;
|
||||
final String l1Title;
|
||||
final List<MorphologicalTag> tags;
|
||||
|
||||
MorphologicalFeature({
|
||||
required this.code,
|
||||
required this.l2Title,
|
||||
required this.l1Title,
|
||||
required this.tags,
|
||||
});
|
||||
|
||||
factory MorphologicalFeature.fromJson(Map<String, dynamic> json) {
|
||||
final tagsFromJson = json['tags'] as List;
|
||||
final List<MorphologicalTag> tagsList =
|
||||
tagsFromJson.map((tag) => MorphologicalTag.fromJson(tag)).toList();
|
||||
|
||||
return MorphologicalFeature(
|
||||
code: json['code'],
|
||||
l2Title: json['l2_title'],
|
||||
l1Title: json['l1_title'],
|
||||
tags: tagsList,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'code': code,
|
||||
'l2_title': l2Title,
|
||||
'l1_title': l1Title,
|
||||
'tags': tags.map((tag) => tag.toJson()).toList(),
|
||||
};
|
||||
}
|
||||
|
||||
MorphologicalTag? getTagByCode(String code) {
|
||||
return tags.firstWhereOrNull(
|
||||
(tag) => tag.code.toLowerCase() == code.toLowerCase());
|
||||
}
|
||||
}
|
||||
|
||||
class MorphInfoResponse {
|
||||
final String userL1;
|
||||
final String userL2;
|
||||
final List<MorphologicalFeature> features;
|
||||
|
||||
MorphInfoResponse({
|
||||
required this.userL1,
|
||||
required this.userL2,
|
||||
required this.features,
|
||||
});
|
||||
|
||||
factory MorphInfoResponse.fromJson(Map<String, dynamic> json) {
|
||||
final featuresFromJson = json['features'] as List;
|
||||
final List<MorphologicalFeature> featuresList = featuresFromJson
|
||||
.map((feature) => MorphologicalFeature.fromJson(feature))
|
||||
.toList();
|
||||
|
||||
return MorphInfoResponse(
|
||||
userL1: json['user_l1'],
|
||||
userL2: json['user_l2'],
|
||||
features: featuresList,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'user_l1': userL1,
|
||||
'user_l2': userL2,
|
||||
'features': features.map((feature) => feature.toJson()).toList(),
|
||||
};
|
||||
}
|
||||
|
||||
MorphologicalFeature? getFeatureByCode(String code) {
|
||||
return features.firstWhereOrNull(
|
||||
(feature) => feature.code.toLowerCase() == code.toLowerCase());
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue