You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
605 lines
18 KiB
Dart
605 lines
18 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:collection/collection.dart';
|
|
import 'package:fluffychat/config/app_config.dart';
|
|
import 'package:fluffychat/pangea/controllers/pangea_controller.dart';
|
|
import 'package:fluffychat/pangea/enum/construct_type_enum.dart';
|
|
import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart';
|
|
import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_representation_event.dart';
|
|
import 'package:fluffychat/pangea/models/analytics/constructs_model.dart';
|
|
import 'package:fluffychat/pangea/models/pangea_match_model.dart';
|
|
import 'package:fluffychat/pangea/pages/analytics/base_analytics.dart';
|
|
import 'package:fluffychat/pangea/utils/error_handler.dart';
|
|
import 'package:fluffychat/utils/date_time_extension.dart';
|
|
import 'package:fluffychat/utils/string_color.dart';
|
|
import 'package:fluffychat/widgets/matrix.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_gen/gen_l10n/l10n.dart';
|
|
import 'package:matrix/matrix.dart';
|
|
|
|
class ConstructList extends StatefulWidget {
|
|
final ConstructType constructType;
|
|
final AnalyticsSelected defaultSelected;
|
|
final AnalyticsSelected? selected;
|
|
final BaseAnalyticsController controller;
|
|
final PangeaController pangeaController;
|
|
final StreamController refreshStream;
|
|
|
|
const ConstructList({
|
|
super.key,
|
|
required this.constructType,
|
|
required this.defaultSelected,
|
|
required this.controller,
|
|
required this.pangeaController,
|
|
required this.refreshStream,
|
|
this.selected,
|
|
});
|
|
|
|
@override
|
|
State<StatefulWidget> createState() => ConstructListState();
|
|
}
|
|
|
|
class ConstructListState extends State<ConstructList> {
|
|
bool initialized = false;
|
|
String? langCode;
|
|
String? error;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
widget.pangeaController.analytics
|
|
.setConstructs(
|
|
constructType: widget.constructType,
|
|
removeIT: true,
|
|
defaultSelected: widget.defaultSelected,
|
|
selected: widget.selected,
|
|
forceUpdate: true,
|
|
)
|
|
.whenComplete(() => setState(() => initialized = true));
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return error != null
|
|
? Center(
|
|
child: Text(error!),
|
|
)
|
|
: Column(
|
|
children: [
|
|
ConstructListView(
|
|
init: initialized,
|
|
controller: widget.controller,
|
|
pangeaController: widget.pangeaController,
|
|
defaultSelected: widget.defaultSelected,
|
|
selected: widget.selected,
|
|
refreshStream: widget.refreshStream,
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
// list view of construct events
|
|
// parameters
|
|
// 1) a list of construct events and
|
|
// 2) a boolean indicating whether the list has been initialized
|
|
// if not initialized, show loading indicator
|
|
// for each tile,
|
|
// title = construct.content.lemma
|
|
// subtitle = total uses, equal to construct.content.uses.length
|
|
// list has a fixed height of 400 and is scrollable
|
|
class ConstructListView extends StatefulWidget {
|
|
final bool init;
|
|
final BaseAnalyticsController controller;
|
|
final PangeaController pangeaController;
|
|
final AnalyticsSelected defaultSelected;
|
|
final AnalyticsSelected? selected;
|
|
final StreamController refreshStream;
|
|
|
|
const ConstructListView({
|
|
super.key,
|
|
required this.init,
|
|
required this.controller,
|
|
required this.pangeaController,
|
|
required this.defaultSelected,
|
|
required this.refreshStream,
|
|
this.selected,
|
|
});
|
|
|
|
@override
|
|
State<StatefulWidget> createState() => ConstructListViewState();
|
|
}
|
|
|
|
class ConstructListViewState extends State<ConstructListView> {
|
|
final ConstructType constructType = ConstructType.grammar;
|
|
final Map<String, Timeline> _timelinesCache = {};
|
|
final Map<String, PangeaMessageEvent> _msgEventCache = {};
|
|
final List<PangeaMessageEvent> _msgEvents = [];
|
|
bool fetchingUses = false;
|
|
StreamSubscription? refreshSubscription;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
refreshSubscription = widget.refreshStream.stream.listen((forceUpdate) {
|
|
widget.pangeaController.analytics
|
|
.setConstructs(
|
|
constructType: constructType,
|
|
removeIT: true,
|
|
defaultSelected: widget.defaultSelected,
|
|
selected: widget.selected,
|
|
forceUpdate: true,
|
|
)
|
|
.then((_) => setState(() {}));
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
refreshSubscription?.cancel();
|
|
super.dispose();
|
|
}
|
|
|
|
int get lemmaIndex =>
|
|
constructs?.indexWhere(
|
|
(element) => element.lemma == widget.controller.currentLemma,
|
|
) ??
|
|
-1;
|
|
|
|
Future<PangeaMessageEvent?> getMessageEvent(
|
|
OneConstructUse use,
|
|
) async {
|
|
final Client client = Matrix.of(context).client;
|
|
PangeaMessageEvent msgEvent;
|
|
if (_msgEventCache.containsKey(use.msgId!)) {
|
|
return _msgEventCache[use.msgId!]!;
|
|
}
|
|
final Room? msgRoom = use.getRoom(client);
|
|
if (msgRoom == null || use.msgId == null) {
|
|
return null;
|
|
}
|
|
|
|
Timeline? timeline;
|
|
if (_timelinesCache.containsKey(use.chatId)) {
|
|
timeline = _timelinesCache[use.chatId];
|
|
} else {
|
|
timeline = await msgRoom.getTimeline();
|
|
_timelinesCache[use.chatId] = timeline;
|
|
}
|
|
|
|
final Event? event = await use.getEvent(client);
|
|
if (event == null || timeline == null) {
|
|
return null;
|
|
}
|
|
|
|
msgEvent = PangeaMessageEvent(
|
|
event: event,
|
|
timeline: timeline,
|
|
ownMessage: event.senderId == client.userID,
|
|
);
|
|
_msgEventCache[use.msgId!] = msgEvent;
|
|
return msgEvent;
|
|
}
|
|
|
|
Future<void> fetchUses() async {
|
|
if (fetchingUses) return;
|
|
if (currentConstruct == null) {
|
|
setState(() => _msgEvents.clear());
|
|
return;
|
|
}
|
|
|
|
setState(() => fetchingUses = true);
|
|
try {
|
|
final List<OneConstructUse> uses = currentConstruct!.uses;
|
|
_msgEvents.clear();
|
|
|
|
for (final OneConstructUse use in uses) {
|
|
final PangeaMessageEvent? msgEvent = await getMessageEvent(use);
|
|
final RepresentationEvent? repEvent =
|
|
msgEvent?.originalSent ?? msgEvent?.originalWritten;
|
|
if (repEvent?.choreo == null) {
|
|
continue;
|
|
}
|
|
_msgEvents.add(msgEvent!);
|
|
}
|
|
setState(() => fetchingUses = false);
|
|
} catch (err, s) {
|
|
setState(() => fetchingUses = false);
|
|
debugPrint("Error fetching uses: $err");
|
|
ErrorHandler.logError(
|
|
e: err,
|
|
s: s,
|
|
m: "Failed to fetch uses for current construct ${currentConstruct?.lemma}",
|
|
);
|
|
}
|
|
}
|
|
|
|
List<ConstructUses>? get constructs {
|
|
if (widget.pangeaController.analytics.constructs == null) {
|
|
return null;
|
|
}
|
|
|
|
final List<OneConstructUse> filtered =
|
|
List.from(widget.pangeaController.analytics.constructs!)
|
|
.map((event) => event.content.uses)
|
|
.expand((uses) => uses)
|
|
.cast<OneConstructUse>()
|
|
.where((use) => use.constructType == constructType)
|
|
.toList();
|
|
|
|
final Map<String, List<OneConstructUse>> lemmaToUses = {};
|
|
for (final use in filtered) {
|
|
if (use.lemma == null) continue;
|
|
lemmaToUses[use.lemma!] ??= [];
|
|
lemmaToUses[use.lemma!]!.add(use);
|
|
}
|
|
|
|
final constructUses = lemmaToUses.entries
|
|
.map(
|
|
(entry) => ConstructUses(
|
|
lemma: entry.key,
|
|
uses: entry.value,
|
|
constructType: constructType,
|
|
),
|
|
)
|
|
.toList();
|
|
|
|
constructUses.sort((a, b) {
|
|
final comp = b.uses.length.compareTo(a.uses.length);
|
|
if (comp != 0) return comp;
|
|
return a.lemma.compareTo(b.lemma);
|
|
});
|
|
|
|
return constructUses;
|
|
}
|
|
|
|
ConstructUses? get currentConstruct => constructs?.firstWhereOrNull(
|
|
(element) => element.lemma == widget.controller.currentLemma,
|
|
);
|
|
|
|
// given the current lemma and list of message events, return a list of
|
|
// MessageEventMatch objects, which contain one PangeaMessageEvent to one PangeaMatch
|
|
// this is because some message events may have has more than one PangeaMatch of a
|
|
// given lemma type.
|
|
List<MessageEventMatch> getMessageEventMatches() {
|
|
if (widget.controller.currentLemma == null) return [];
|
|
final List<MessageEventMatch> allMsgErrorSteps = [];
|
|
|
|
for (final msgEvent in _msgEvents) {
|
|
if (allMsgErrorSteps.any(
|
|
(element) => element.msgEvent.eventId == msgEvent.eventId,
|
|
)) {
|
|
continue;
|
|
}
|
|
// get all the pangea matches in that message which have that lemma
|
|
final List<PangeaMatch>? msgErrorSteps = msgEvent.errorSteps(
|
|
widget.controller.currentLemma!,
|
|
);
|
|
if (msgErrorSteps == null) continue;
|
|
|
|
allMsgErrorSteps.addAll(
|
|
msgErrorSteps.map(
|
|
(errorStep) => MessageEventMatch(
|
|
msgEvent: msgEvent,
|
|
lemmaMatch: errorStep,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
return allMsgErrorSteps;
|
|
}
|
|
|
|
Future<void> showConstructMessagesDialog() async {
|
|
await showDialog<ConstructMessagesDialog>(
|
|
context: context,
|
|
builder: (c) => ConstructMessagesDialog(controller: this),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (!widget.init || fetchingUses) {
|
|
return const Expanded(
|
|
child: Center(child: CircularProgressIndicator()),
|
|
);
|
|
}
|
|
|
|
if (constructs?.isEmpty ?? true) {
|
|
return Expanded(
|
|
child: Center(child: Text(L10n.of(context)!.noDataFound)),
|
|
);
|
|
}
|
|
|
|
return Expanded(
|
|
child: ListView.builder(
|
|
itemCount: constructs!.length,
|
|
itemBuilder: (context, index) {
|
|
return ListTile(
|
|
title: Text(
|
|
constructs![index].lemma,
|
|
),
|
|
subtitle: Text(
|
|
'${L10n.of(context)!.total} ${constructs![index].uses.length}',
|
|
),
|
|
onTap: () async {
|
|
final String lemma = constructs![index].lemma;
|
|
widget.controller.setCurrentLemma(lemma);
|
|
fetchUses().then((_) => showConstructMessagesDialog());
|
|
},
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class ConstructMessagesDialog extends StatelessWidget {
|
|
final ConstructListViewState controller;
|
|
const ConstructMessagesDialog({
|
|
super.key,
|
|
required this.controller,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (controller.widget.controller.currentLemma == null) {
|
|
return const AlertDialog(content: CircularProgressIndicator.adaptive());
|
|
}
|
|
|
|
final msgEventMatches = controller.getMessageEventMatches();
|
|
|
|
return AlertDialog(
|
|
title: Center(child: Text(controller.widget.controller.currentLemma!)),
|
|
content: SizedBox(
|
|
height: 350,
|
|
width: 500,
|
|
child: Column(
|
|
children: [
|
|
if (controller.constructs![controller.lemmaIndex].uses.length >
|
|
controller._msgEvents.length)
|
|
Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(8.0),
|
|
child: Text(L10n.of(context)!.roomDataMissing),
|
|
),
|
|
),
|
|
Expanded(
|
|
child: ListView(
|
|
children: [
|
|
...msgEventMatches.mapIndexed(
|
|
(index, event) => Column(
|
|
children: [
|
|
ConstructMessage(
|
|
msgEvent: event.msgEvent,
|
|
lemma: controller.widget.controller.currentLemma!,
|
|
errorMessage: event.lemmaMatch,
|
|
),
|
|
if (index < msgEventMatches.length - 1)
|
|
const Divider(height: 1),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(context, rootNavigator: false).pop(),
|
|
child: Text(
|
|
L10n.of(context)!.close.toUpperCase(),
|
|
style: TextStyle(
|
|
color:
|
|
Theme.of(context).textTheme.bodyMedium?.color?.withAlpha(150),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class ConstructMessage extends StatelessWidget {
|
|
final PangeaMessageEvent msgEvent;
|
|
final PangeaMatch errorMessage;
|
|
final String lemma;
|
|
|
|
const ConstructMessage({
|
|
super.key,
|
|
required this.msgEvent,
|
|
required this.errorMessage,
|
|
required this.lemma,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final String? chosen = errorMessage.match.choices
|
|
?.firstWhereOrNull(
|
|
(element) => element.selected == true,
|
|
)
|
|
?.value;
|
|
|
|
if (chosen == null) {
|
|
return const SizedBox.shrink();
|
|
}
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.all(20),
|
|
child: Row(
|
|
children: [
|
|
ConstructMessageMetadata(msgEvent: msgEvent),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
children: [
|
|
Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
FutureBuilder<User?>(
|
|
future: msgEvent.event.fetchSenderUser(),
|
|
builder: (context, snapshot) {
|
|
final displayname = snapshot.data?.calcDisplayname() ??
|
|
msgEvent.event.senderFromMemoryOrFallback
|
|
.calcDisplayname();
|
|
return Text(
|
|
displayname,
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.bold,
|
|
color: (Theme.of(context).brightness ==
|
|
Brightness.light
|
|
? displayname.color
|
|
: displayname.lightColorText),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
ConstructMessageBubble(
|
|
errorText: errorMessage.match.fullText,
|
|
replacementText: chosen,
|
|
start: errorMessage.match.offset,
|
|
end:
|
|
errorMessage.match.offset + errorMessage.match.length,
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class ConstructMessageBubble extends StatelessWidget {
|
|
final String errorText;
|
|
final String replacementText;
|
|
final int start;
|
|
final int end;
|
|
|
|
const ConstructMessageBubble({
|
|
super.key,
|
|
required this.errorText,
|
|
required this.replacementText,
|
|
required this.start,
|
|
required this.end,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final defaultStyle = TextStyle(
|
|
color: Theme.of(context).colorScheme.onSurface,
|
|
fontSize: AppConfig.messageFontSize * AppConfig.fontSizeFactor,
|
|
height: 1.3,
|
|
);
|
|
|
|
return IntrinsicWidth(
|
|
child: Material(
|
|
color: Theme.of(context).colorScheme.primaryContainer,
|
|
clipBehavior: Clip.antiAlias,
|
|
shape: const RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.only(
|
|
topLeft: Radius.circular(4),
|
|
topRight: Radius.circular(AppConfig.borderRadius),
|
|
bottomLeft: Radius.circular(AppConfig.borderRadius),
|
|
bottomRight: Radius.circular(AppConfig.borderRadius),
|
|
),
|
|
),
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(
|
|
AppConfig.borderRadius,
|
|
),
|
|
),
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 16,
|
|
vertical: 8,
|
|
),
|
|
child: RichText(
|
|
text: (end == null)
|
|
? TextSpan(
|
|
text: errorText,
|
|
style: defaultStyle,
|
|
)
|
|
: TextSpan(
|
|
children: [
|
|
TextSpan(
|
|
text: errorText.substring(0, start),
|
|
style: defaultStyle,
|
|
),
|
|
TextSpan(
|
|
text: errorText.substring(start, end),
|
|
style: defaultStyle.merge(
|
|
TextStyle(
|
|
backgroundColor: Colors.red.withOpacity(0.25),
|
|
decoration: TextDecoration.lineThrough,
|
|
decorationThickness: 2.5,
|
|
),
|
|
),
|
|
),
|
|
const TextSpan(text: " "),
|
|
TextSpan(
|
|
text: replacementText,
|
|
style: defaultStyle.merge(
|
|
TextStyle(
|
|
backgroundColor: Colors.green.withOpacity(0.25),
|
|
),
|
|
),
|
|
),
|
|
TextSpan(
|
|
text: errorText.substring(end),
|
|
style: defaultStyle,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class ConstructMessageMetadata extends StatelessWidget {
|
|
final PangeaMessageEvent msgEvent;
|
|
|
|
const ConstructMessageMetadata({
|
|
super.key,
|
|
required this.msgEvent,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final String roomName = msgEvent.event.room.getLocalizedDisplayname();
|
|
return Padding(
|
|
padding: const EdgeInsets.fromLTRB(10, 0, 30, 0),
|
|
child: Column(
|
|
children: [
|
|
Text(
|
|
msgEvent.event.originServerTs.localizedTime(context),
|
|
style: TextStyle(fontSize: 13 * AppConfig.fontSizeFactor),
|
|
),
|
|
Text(roomName),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class MessageEventMatch {
|
|
final PangeaMessageEvent msgEvent;
|
|
final PangeaMatch lemmaMatch;
|
|
|
|
MessageEventMatch({
|
|
required this.msgEvent,
|
|
required this.lemmaMatch,
|
|
});
|
|
}
|