resolve merge conflicts
This commit is contained in:
commit
ee05510737
93 changed files with 64658 additions and 128794 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -81,6 +81,10 @@ olm
|
|||
needed-translations.txt
|
||||
.venv
|
||||
|
||||
# Generated files from find_unused_intl_keys.py
|
||||
scripts/unused_intl_keys_report.txt
|
||||
scripts/unused_intl_keys.json
|
||||
|
||||
docs/node_modules/.package-lock.json
|
||||
docs/node_modules/.bin/detect-libc
|
||||
docs/node_modules/.bin/jiti
|
||||
|
|
|
|||
3662
lib/l10n/intl_ar.arb
3662
lib/l10n/intl_ar.arb
File diff suppressed because it is too large
Load diff
3662
lib/l10n/intl_be.arb
3662
lib/l10n/intl_be.arb
File diff suppressed because it is too large
Load diff
3662
lib/l10n/intl_bn.arb
3662
lib/l10n/intl_bn.arb
File diff suppressed because it is too large
Load diff
2386
lib/l10n/intl_bo.arb
2386
lib/l10n/intl_bo.arb
File diff suppressed because it is too large
Load diff
3662
lib/l10n/intl_ca.arb
3662
lib/l10n/intl_ca.arb
File diff suppressed because it is too large
Load diff
3662
lib/l10n/intl_cs.arb
3662
lib/l10n/intl_cs.arb
File diff suppressed because it is too large
Load diff
3662
lib/l10n/intl_da.arb
3662
lib/l10n/intl_da.arb
File diff suppressed because it is too large
Load diff
3662
lib/l10n/intl_de.arb
3662
lib/l10n/intl_de.arb
File diff suppressed because it is too large
Load diff
3662
lib/l10n/intl_el.arb
3662
lib/l10n/intl_el.arb
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
3662
lib/l10n/intl_eo.arb
3662
lib/l10n/intl_eo.arb
File diff suppressed because it is too large
Load diff
2072
lib/l10n/intl_es.arb
2072
lib/l10n/intl_es.arb
File diff suppressed because it is too large
Load diff
3662
lib/l10n/intl_et.arb
3662
lib/l10n/intl_et.arb
File diff suppressed because it is too large
Load diff
3662
lib/l10n/intl_eu.arb
3662
lib/l10n/intl_eu.arb
File diff suppressed because it is too large
Load diff
3662
lib/l10n/intl_fa.arb
3662
lib/l10n/intl_fa.arb
File diff suppressed because it is too large
Load diff
3684
lib/l10n/intl_fi.arb
3684
lib/l10n/intl_fi.arb
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
3662
lib/l10n/intl_fr.arb
3662
lib/l10n/intl_fr.arb
File diff suppressed because it is too large
Load diff
3662
lib/l10n/intl_ga.arb
3662
lib/l10n/intl_ga.arb
File diff suppressed because it is too large
Load diff
3662
lib/l10n/intl_gl.arb
3662
lib/l10n/intl_gl.arb
File diff suppressed because it is too large
Load diff
3662
lib/l10n/intl_he.arb
3662
lib/l10n/intl_he.arb
File diff suppressed because it is too large
Load diff
3662
lib/l10n/intl_hi.arb
3662
lib/l10n/intl_hi.arb
File diff suppressed because it is too large
Load diff
3662
lib/l10n/intl_hr.arb
3662
lib/l10n/intl_hr.arb
File diff suppressed because it is too large
Load diff
3662
lib/l10n/intl_hu.arb
3662
lib/l10n/intl_hu.arb
File diff suppressed because it is too large
Load diff
3662
lib/l10n/intl_ia.arb
3662
lib/l10n/intl_ia.arb
File diff suppressed because it is too large
Load diff
3662
lib/l10n/intl_id.arb
3662
lib/l10n/intl_id.arb
File diff suppressed because it is too large
Load diff
3662
lib/l10n/intl_ie.arb
3662
lib/l10n/intl_ie.arb
File diff suppressed because it is too large
Load diff
3662
lib/l10n/intl_it.arb
3662
lib/l10n/intl_it.arb
File diff suppressed because it is too large
Load diff
3662
lib/l10n/intl_ja.arb
3662
lib/l10n/intl_ja.arb
File diff suppressed because it is too large
Load diff
3662
lib/l10n/intl_ka.arb
3662
lib/l10n/intl_ka.arb
File diff suppressed because it is too large
Load diff
3662
lib/l10n/intl_ko.arb
3662
lib/l10n/intl_ko.arb
File diff suppressed because it is too large
Load diff
3662
lib/l10n/intl_lt.arb
3662
lib/l10n/intl_lt.arb
File diff suppressed because it is too large
Load diff
3662
lib/l10n/intl_lv.arb
3662
lib/l10n/intl_lv.arb
File diff suppressed because it is too large
Load diff
3662
lib/l10n/intl_nb.arb
3662
lib/l10n/intl_nb.arb
File diff suppressed because it is too large
Load diff
3662
lib/l10n/intl_nl.arb
3662
lib/l10n/intl_nl.arb
File diff suppressed because it is too large
Load diff
3662
lib/l10n/intl_pl.arb
3662
lib/l10n/intl_pl.arb
File diff suppressed because it is too large
Load diff
3662
lib/l10n/intl_pt.arb
3662
lib/l10n/intl_pt.arb
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
3662
lib/l10n/intl_ro.arb
3662
lib/l10n/intl_ro.arb
File diff suppressed because it is too large
Load diff
3662
lib/l10n/intl_ru.arb
3662
lib/l10n/intl_ru.arb
File diff suppressed because it is too large
Load diff
3662
lib/l10n/intl_sk.arb
3662
lib/l10n/intl_sk.arb
File diff suppressed because it is too large
Load diff
3662
lib/l10n/intl_sl.arb
3662
lib/l10n/intl_sl.arb
File diff suppressed because it is too large
Load diff
3662
lib/l10n/intl_sr.arb
3662
lib/l10n/intl_sr.arb
File diff suppressed because it is too large
Load diff
3662
lib/l10n/intl_sv.arb
3662
lib/l10n/intl_sv.arb
File diff suppressed because it is too large
Load diff
3662
lib/l10n/intl_ta.arb
3662
lib/l10n/intl_ta.arb
File diff suppressed because it is too large
Load diff
3662
lib/l10n/intl_te.arb
3662
lib/l10n/intl_te.arb
File diff suppressed because it is too large
Load diff
3662
lib/l10n/intl_th.arb
3662
lib/l10n/intl_th.arb
File diff suppressed because it is too large
Load diff
3662
lib/l10n/intl_tr.arb
3662
lib/l10n/intl_tr.arb
File diff suppressed because it is too large
Load diff
3662
lib/l10n/intl_uk.arb
3662
lib/l10n/intl_uk.arb
File diff suppressed because it is too large
Load diff
2067
lib/l10n/intl_vi.arb
2067
lib/l10n/intl_vi.arb
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
3662
lib/l10n/intl_zh.arb
3662
lib/l10n/intl_zh.arb
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -244,39 +244,36 @@ class ChatView extends StatelessWidget {
|
|||
// #Pangea
|
||||
: controller.widget.backButton != null
|
||||
? controller.widget.backButton!
|
||||
// : FluffyThemes.isColumnMode(context)
|
||||
// ? null
|
||||
// Pangea#
|
||||
: FluffyThemes.isColumnMode(context)
|
||||
? null
|
||||
: StreamBuilder<Object>(
|
||||
stream: Matrix.of(context)
|
||||
.client
|
||||
.onSync
|
||||
.stream
|
||||
.where(
|
||||
: StreamBuilder<Object>(
|
||||
stream:
|
||||
Matrix.of(context).client.onSync.stream.where(
|
||||
(syncUpdate) => syncUpdate.hasRoomUpdate,
|
||||
),
|
||||
// #Pangea
|
||||
// builder: (context, _) => UnreadRoomsBadge(
|
||||
// filter: (r) => r.id != controller.roomId,
|
||||
// badgePosition:
|
||||
// BadgePosition.topEnd(end: 8, top: 4),
|
||||
// child: const Center(child: BackButton()),
|
||||
// ),
|
||||
builder: (context, _) => Center(
|
||||
child: SizedBox(
|
||||
height: kToolbarHeight,
|
||||
child: UnreadRoomsBadge(
|
||||
filter: (r) => r.id != controller.roomId,
|
||||
badgePosition: BadgePosition.topEnd(
|
||||
end: 8,
|
||||
top: 4,
|
||||
),
|
||||
child: const Center(child: BackButton()),
|
||||
),
|
||||
// #Pangea
|
||||
// builder: (context, _) => UnreadRoomsBadge(
|
||||
// filter: (r) => r.id != controller.roomId,
|
||||
// badgePosition:
|
||||
// BadgePosition.topEnd(end: 8, top: 4),
|
||||
// child: const Center(child: BackButton()),
|
||||
// ),
|
||||
builder: (context, _) => Center(
|
||||
child: SizedBox(
|
||||
height: kToolbarHeight,
|
||||
child: UnreadRoomsBadge(
|
||||
filter: (r) => r.id != controller.roomId,
|
||||
badgePosition: BadgePosition.topEnd(
|
||||
end: 8,
|
||||
top: 4,
|
||||
),
|
||||
child: const Center(child: BackButton()),
|
||||
),
|
||||
// Pangea#
|
||||
),
|
||||
),
|
||||
// Pangea#
|
||||
),
|
||||
titleSpacing: FluffyThemes.isColumnMode(context) ? 24 : 0,
|
||||
title: ChatAppBarTitle(controller),
|
||||
actions: _appBarActions(context),
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@ import 'package:fluffychat/pangea/message_token_text/tokens_util.dart';
|
|||
import 'package:fluffychat/pangea/toolbar/enums/reading_assistance_mode_enum.dart';
|
||||
import 'package:fluffychat/pangea/toolbar/utils/token_rendering_util.dart';
|
||||
import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.dart';
|
||||
import 'package:fluffychat/pangea/toolbar/widgets/select_mode_buttons.dart';
|
||||
import 'package:fluffychat/utils/event_checkbox_extension.dart';
|
||||
import 'package:fluffychat/widgets/avatar.dart';
|
||||
import 'package:fluffychat/widgets/future_loading_dialog.dart';
|
||||
|
|
@ -441,14 +440,14 @@ class HtmlMessage extends StatelessWidget {
|
|||
: PlaceholderAlignment.middle,
|
||||
child: Column(
|
||||
children: [
|
||||
if (token != null &&
|
||||
overlayController?.selectedMode == SelectMode.emoji)
|
||||
if (token != null && overlayController != null)
|
||||
TokenEmojiButton(
|
||||
token: token,
|
||||
eventId: event.eventId,
|
||||
enabled: token.lemma.saveVocab,
|
||||
emoji: token.vocabConstructID.userSetEmoji.firstOrNull,
|
||||
targetId: overlayController!.tokenEmojiPopupKey(token),
|
||||
onSelect: () =>
|
||||
overlayController!.showTokenEmojiPopup(token),
|
||||
selectModeNotifier: overlayController!.selectedMode,
|
||||
),
|
||||
if (renderer.showCenterStyling && token != null)
|
||||
TokenPracticeButton(
|
||||
|
|
@ -942,11 +941,11 @@ class HtmlMessage extends StatelessWidget {
|
|||
: PlaceholderAlignment.middle,
|
||||
child: Column(
|
||||
children: [
|
||||
if (node.localName == 'nontoken' &&
|
||||
overlayController?.selectedMode == SelectMode.emoji)
|
||||
if (node.localName == 'nontoken' && overlayController != null)
|
||||
// Use TokenEmojiButton to ensure consistent vertical alignment for non-token elements (e.g., emojis) in practice mode.
|
||||
TokenEmojiButton(
|
||||
token: null,
|
||||
eventId: event.eventId,
|
||||
selectModeNotifier: overlayController!.selectedMode,
|
||||
enabled: false,
|
||||
),
|
||||
RichText(
|
||||
text: TextSpan(
|
||||
|
|
|
|||
|
|
@ -56,28 +56,25 @@ class ActivityRolesEvent extends StatelessWidget {
|
|||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 4.0),
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(4),
|
||||
child: Material(
|
||||
color: theme.colorScheme.surface.withAlpha(128),
|
||||
borderRadius: BorderRadius.circular(AppConfig.borderRadius / 3),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8.0,
|
||||
vertical: 4.0,
|
||||
),
|
||||
child: Text(
|
||||
"${role.stateEventMessage(displayName, L10n.of(context))}",
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 12 * AppConfig.fontSizeFactor,
|
||||
decoration:
|
||||
event.redacted ? TextDecoration.lineThrough : null,
|
||||
),
|
||||
child: Material(
|
||||
color: theme.colorScheme.surface.withAlpha(128),
|
||||
borderRadius: BorderRadius.circular(AppConfig.borderRadius / 3),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8.0,
|
||||
vertical: 4.0,
|
||||
),
|
||||
child: Text(
|
||||
"${role.stateEventMessage(displayName, L10n.of(context))}",
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 12 * AppConfig.fontSizeFactor,
|
||||
decoration:
|
||||
event.redacted ? TextDecoration.lineThrough : null,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -89,7 +89,7 @@ class ActivitySummary extends StatelessWidget {
|
|||
),
|
||||
DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surface.withAlpha(220),
|
||||
color: theme.colorScheme.surface.withAlpha(128),
|
||||
borderRadius: BorderRadius.circular(12.0),
|
||||
),
|
||||
child: Padding(
|
||||
|
|
@ -101,7 +101,7 @@ class ActivitySummary extends StatelessWidget {
|
|||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
InkWell(
|
||||
hoverColor: theme.colorScheme.surfaceTint.withAlpha(200),
|
||||
hoverColor: theme.colorScheme.surfaceTint.withAlpha(55),
|
||||
onTap: toggleInstructions,
|
||||
child: Column(
|
||||
spacing: 4.0,
|
||||
|
|
|
|||
|
|
@ -34,12 +34,34 @@ class ActivityUserSummaries extends StatelessWidget {
|
|||
spacing: 4.0,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
L10n.of(context).activityFinishedMessage,
|
||||
),
|
||||
Text(
|
||||
summary.summary,
|
||||
textAlign: TextAlign.center,
|
||||
Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 12.0, vertical: 4.0),
|
||||
child: Center(
|
||||
child: Material(
|
||||
color: Theme.of(context).colorScheme.surface.withAlpha(128),
|
||||
borderRadius: BorderRadius.circular(AppConfig.borderRadius / 3),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8.0,
|
||||
vertical: 4.0,
|
||||
),
|
||||
child: Column(
|
||||
spacing: 4.0,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
L10n.of(context).activityFinishedMessage,
|
||||
),
|
||||
Text(
|
||||
summary.summary,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
|
|
|
|||
|
|
@ -194,6 +194,10 @@ class PangeaInvitationSelectionController
|
|||
if (a.id == client.userID) return -1;
|
||||
if (b.id == client.userID) return 1;
|
||||
|
||||
// sort the bot to the top
|
||||
if (a.id == BotName.byEnvironment) return -1;
|
||||
if (b.id == BotName.byEnvironment) return 1;
|
||||
|
||||
if (participants != null) {
|
||||
final participantA = participants!.firstWhereOrNull((u) => u.id == a.id);
|
||||
final participantB = participants!.firstWhereOrNull((u) => u.id == b.id);
|
||||
|
|
@ -264,25 +268,27 @@ class PangeaInvitationSelectionController
|
|||
)
|
||||
.toList();
|
||||
|
||||
contacts.removeWhere((u) => u.id == BotName.byEnvironment);
|
||||
if (_room?.isSpace ?? false) {
|
||||
contacts.removeWhere((u) => u.id == BotName.byEnvironment);
|
||||
}
|
||||
contacts.sort(_sortUsers);
|
||||
return contacts;
|
||||
}
|
||||
|
||||
List<User> getContacts(BuildContext context) {
|
||||
final client = Matrix.of(context).client;
|
||||
participants!.removeWhere(
|
||||
(u) => ![Membership.join, Membership.invite].contains(u.membership),
|
||||
);
|
||||
final contacts = client.rooms
|
||||
.where((r) => r.isDirectChat)
|
||||
.map((r) => r.unsafeGetUserFromMemoryOrFallback(r.directChatMatrixID!))
|
||||
.toList();
|
||||
contacts.sort(
|
||||
(a, b) => a.calcDisplayname().toLowerCase().compareTo(
|
||||
b.calcDisplayname().toLowerCase(),
|
||||
),
|
||||
);
|
||||
|
||||
if (_room?.isSpace == false &&
|
||||
!contacts.any((u) => u.id == BotName.byEnvironment)) {
|
||||
final bot = _room?.unsafeGetUserFromMemoryOrFallback(
|
||||
BotName.byEnvironment,
|
||||
);
|
||||
if (bot != null) contacts.add(bot);
|
||||
}
|
||||
return contacts;
|
||||
}
|
||||
|
||||
|
|
@ -341,7 +347,9 @@ class PangeaInvitationSelectionController
|
|||
}
|
||||
|
||||
final results = response.results;
|
||||
results.removeWhere((profile) => profile.userId == BotName.byEnvironment);
|
||||
if (_room?.isSpace ?? false) {
|
||||
results.removeWhere((profile) => profile.userId == BotName.byEnvironment);
|
||||
}
|
||||
|
||||
setState(() {
|
||||
foundProfiles = List<Profile>.from(results);
|
||||
|
|
@ -364,8 +372,7 @@ class PangeaInvitationSelectionController
|
|||
|
||||
foundProfiles.removeWhere(
|
||||
(profile) =>
|
||||
participants?.indexWhere((u) => u.id == profile.userId) != -1 ||
|
||||
BotName.byEnvironment == profile.userId,
|
||||
participants?.indexWhere((u) => u.id == profile.userId) != -1,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ class ButtonDetails {
|
|||
final bool visible;
|
||||
final bool enabled;
|
||||
final bool showInMainView;
|
||||
final bool isToggle;
|
||||
final bool value;
|
||||
final SpaceSettingsTabs? tab;
|
||||
|
||||
const ButtonDetails({
|
||||
|
|
@ -20,6 +22,8 @@ class ButtonDetails {
|
|||
this.visible = true,
|
||||
this.enabled = true,
|
||||
this.onPressed,
|
||||
this.isToggle = false,
|
||||
this.value = false,
|
||||
this.showInMainView = true,
|
||||
this.tab,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import 'package:go_router/go_router.dart';
|
|||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
import 'package:fluffychat/config/app_config.dart';
|
||||
import 'package:fluffychat/config/themes.dart';
|
||||
import 'package:fluffychat/l10n/l10n.dart';
|
||||
import 'package:fluffychat/pages/chat_details/chat_details.dart';
|
||||
|
|
@ -122,6 +123,19 @@ class SpaceDetailsContent extends StatelessWidget {
|
|||
enabled: room.isRoomAdmin,
|
||||
showInMainView: false,
|
||||
),
|
||||
ButtonDetails(
|
||||
title: L10n.of(context).teacherModeTitle,
|
||||
description: L10n.of(context).teacherModeDesc,
|
||||
icon: const Icon(Icons.school_outlined, size: 30.0),
|
||||
onPressed: () => showFutureLoadingDialog(
|
||||
context: context,
|
||||
future: () => room.setTeacherMode(!room.isTeacherMode),
|
||||
),
|
||||
enabled: room.isRoomAdmin,
|
||||
showInMainView: false,
|
||||
isToggle: true,
|
||||
value: room.isTeacherMode,
|
||||
),
|
||||
ButtonDetails(
|
||||
title: l10n.permissions,
|
||||
description: l10n.permissionsDesc,
|
||||
|
|
@ -327,16 +341,32 @@ class SpaceDetailsContent extends StatelessWidget {
|
|||
children: buttons.map((b) {
|
||||
return Opacity(
|
||||
opacity: b.enabled ? 1.0 : 0.5,
|
||||
child: ListTile(
|
||||
title: Text(b.title),
|
||||
subtitle: b.description != null
|
||||
? Text(b.description!)
|
||||
: null,
|
||||
leading: b.icon,
|
||||
onTap: b.enabled
|
||||
? () => b.onPressed?.call()
|
||||
: null,
|
||||
),
|
||||
child: b.isToggle
|
||||
? SwitchListTile(
|
||||
title: Text(b.title),
|
||||
subtitle: b.description != null
|
||||
? Text(b.description!)
|
||||
: null,
|
||||
secondary: b.icon,
|
||||
value: b.value,
|
||||
onChanged: b.enabled
|
||||
? (value) {
|
||||
b.onPressed?.call();
|
||||
}
|
||||
: null,
|
||||
activeThumbColor:
|
||||
AppConfig.activeToggleColor,
|
||||
)
|
||||
: ListTile(
|
||||
title: Text(b.title),
|
||||
subtitle: b.description != null
|
||||
? Text(b.description!)
|
||||
: null,
|
||||
leading: b.icon,
|
||||
onTap: b.enabled
|
||||
? () => b.onPressed?.call()
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -34,7 +34,8 @@ extension SpanDataTypeEnumExt on SpanDataTypeEnum {
|
|||
return L10n.of(context).correctionDefaultPrompt;
|
||||
case SpanDataTypeEnum.itStart:
|
||||
return L10n.of(context).needsItMessage(
|
||||
MatrixState.pangeaController.languageController.userL2?.displayName ??
|
||||
MatrixState.pangeaController.languageController.userL2
|
||||
?.getDisplayName(context) ??
|
||||
L10n.of(context).targetLanguage,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,9 +22,14 @@ class CourseLanguageFilter extends StatelessWidget {
|
|||
onChanged: onChanged,
|
||||
items:
|
||||
MatrixState.pangeaController.pLanguageStore.unlocalizedTargetOptions,
|
||||
displayname: (v) => v.getDisplayName(context) ?? v.displayName,
|
||||
displayname: (v) => v.getDisplayName(context),
|
||||
enableSearch: true,
|
||||
defaultName: L10n.of(context).allLanguages,
|
||||
searchMatchFn: (item, searchValue) => LanguageModel.search(
|
||||
item.value,
|
||||
searchValue,
|
||||
context,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ class CoursePlanFilter<T> extends StatefulWidget {
|
|||
final String Function(T) displayname;
|
||||
|
||||
final bool enableSearch;
|
||||
final bool Function(DropdownMenuItem<T>, String)? searchMatchFn;
|
||||
|
||||
const CoursePlanFilter({
|
||||
super.key,
|
||||
|
|
@ -22,6 +23,7 @@ class CoursePlanFilter<T> extends StatefulWidget {
|
|||
required this.defaultName,
|
||||
required this.displayname,
|
||||
this.enableSearch = false,
|
||||
this.searchMatchFn,
|
||||
});
|
||||
|
||||
@override
|
||||
|
|
@ -85,30 +87,41 @@ class CoursePlanFilterState<T> extends State<CoursePlanFilter<T>> {
|
|||
borderRadius: BorderRadius.circular(40),
|
||||
),
|
||||
),
|
||||
dropdownStyleData: DropdownStyleData(
|
||||
elevation: 8,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
color: theme.colorScheme.surfaceContainerHigh,
|
||||
),
|
||||
),
|
||||
menuItemStyleData: const MenuItemStyleData(
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
dropdownSearchData: widget.enableSearch
|
||||
? DropdownSearchData(
|
||||
searchController: _searchController,
|
||||
searchInnerWidgetHeight: 50,
|
||||
searchInnerWidget: Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
child: TextField(
|
||||
autofocus: true,
|
||||
controller: _searchController,
|
||||
decoration: const InputDecoration(
|
||||
prefixIcon: Icon(Icons.search),
|
||||
searchInnerWidget: Material(
|
||||
elevation: 4,
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(14),
|
||||
topRight: Radius.circular(14),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 14,
|
||||
vertical: 10,
|
||||
),
|
||||
child: TextField(
|
||||
autofocus: true,
|
||||
controller: _searchController,
|
||||
decoration: const InputDecoration(
|
||||
prefixIcon: Icon(Icons.search),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
searchMatchFn: (item, searchValue) {
|
||||
final displayName = (item.value != null
|
||||
? widget.displayname(item.value as T)
|
||||
: widget.defaultName)
|
||||
.toLowerCase();
|
||||
|
||||
final search = searchValue.toLowerCase();
|
||||
return displayName.startsWith(search);
|
||||
},
|
||||
searchMatchFn: widget.searchMatchFn,
|
||||
)
|
||||
: null,
|
||||
onMenuStateChange: (isOpen) {
|
||||
|
|
|
|||
|
|
@ -35,8 +35,10 @@ mixin ActivitySummariesProvider<T extends StatefulWidget> on State<T> {
|
|||
continue;
|
||||
}
|
||||
|
||||
final isOpen = summary.activityRoles.roles.length <
|
||||
summary.activityPlan.req.numberOfParticipants;
|
||||
final isOpen =
|
||||
!summary.activityRoles.roles.values.any((r) => r.isArchived) ||
|
||||
summary.activityRoles.roles.length <
|
||||
summary.activityPlan.req.numberOfParticipants;
|
||||
|
||||
if (isOpen) {
|
||||
sessions.add(roomId);
|
||||
|
|
|
|||
|
|
@ -101,4 +101,22 @@ extension CoursePlanRoomExtension on Room {
|
|||
}
|
||||
return roomID;
|
||||
}
|
||||
|
||||
bool get isTeacherMode {
|
||||
final event = getState(PangeaEventTypes.teacherMode, client.userID!);
|
||||
if (event == null) return false;
|
||||
final content = event.content;
|
||||
return content['enabled'] == true;
|
||||
}
|
||||
|
||||
Future<void> setTeacherMode(bool enabled) async {
|
||||
await client.setRoomStateWithKey(
|
||||
id,
|
||||
PangeaEventTypes.teacherMode,
|
||||
client.userID!,
|
||||
{
|
||||
'enabled': enabled,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -119,6 +119,8 @@ class CourseSettings extends StatelessWidget {
|
|||
controller.course!,
|
||||
);
|
||||
|
||||
final teacherMode = room.isTeacherMode;
|
||||
|
||||
return Column(
|
||||
spacing: isColumnMode ? 40.0 : 36.0,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
|
|
@ -132,9 +134,12 @@ class CourseSettings extends StatelessWidget {
|
|||
final usersInTopic = userTopics[topicId] ?? [];
|
||||
final activityError = controller.activityErrors[topicId];
|
||||
|
||||
final bool locked = topicIndex == null ? false : index > topicIndex;
|
||||
final bool locked =
|
||||
!teacherMode && (topicIndex == null ? false : index > topicIndex);
|
||||
|
||||
final disabled =
|
||||
locked || controller.loadingActivities || activityError != null;
|
||||
|
||||
return AbsorbPointer(
|
||||
absorbing: disabled,
|
||||
child: Opacity(
|
||||
|
|
|
|||
|
|
@ -52,4 +52,5 @@ class PangeaEventTypes {
|
|||
/// Relates to course plans
|
||||
static const coursePlan = "pangea.course_plan";
|
||||
static const courseUser = "p.course_user";
|
||||
static const teacherMode = "pangea.teacher_mode";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||
|
||||
import 'package:collection/collection.dart';
|
||||
|
||||
import 'package:fluffychat/l10n/l10n.dart';
|
||||
import 'package:fluffychat/pangea/learning_settings/constants/language_constants.dart';
|
||||
import 'package:fluffychat/pangea/learning_settings/enums/l2_support_enum.dart';
|
||||
import 'package:fluffychat/pangea/learning_settings/utils/p_language_store.dart';
|
||||
|
|
@ -70,8 +71,233 @@ class LanguageModel {
|
|||
displayName: "Unknown",
|
||||
);
|
||||
|
||||
String? getDisplayName(BuildContext context) {
|
||||
return displayName;
|
||||
String getDisplayName(BuildContext context) {
|
||||
final langKey = "${langCode.replaceAll("-", "")}DisplayName";
|
||||
final l10n = L10n.of(context);
|
||||
|
||||
final displayNameMap = <String, String>{
|
||||
"aceDisplayName": l10n.aceDisplayName,
|
||||
"achDisplayName": l10n.achDisplayName,
|
||||
"afDisplayName": l10n.afDisplayName,
|
||||
"akDisplayName": l10n.akDisplayName,
|
||||
"alzDisplayName": l10n.alzDisplayName,
|
||||
"amDisplayName": l10n.amDisplayName,
|
||||
"arDisplayName": l10n.arDisplayName,
|
||||
"asDisplayName": l10n.asDisplayName,
|
||||
"awaDisplayName": l10n.awaDisplayName,
|
||||
"ayDisplayName": l10n.ayDisplayName,
|
||||
"azDisplayName": l10n.azDisplayName,
|
||||
"baDisplayName": l10n.baDisplayName,
|
||||
"banDisplayName": l10n.banDisplayName,
|
||||
"bbcDisplayName": l10n.bbcDisplayName,
|
||||
"beDisplayName": l10n.beDisplayName,
|
||||
"bemDisplayName": l10n.bemDisplayName,
|
||||
"bewDisplayName": l10n.bewDisplayName,
|
||||
"bgDisplayName": l10n.bgDisplayName,
|
||||
"bhoDisplayName": l10n.bhoDisplayName,
|
||||
"bikDisplayName": l10n.bikDisplayName,
|
||||
"bmDisplayName": l10n.bmDisplayName,
|
||||
"bnDisplayName": l10n.bnDisplayName,
|
||||
"bnBDDisplayName": l10n.bnBDDisplayName,
|
||||
"bnINDisplayName": l10n.bnINDisplayName,
|
||||
"brDisplayName": l10n.brDisplayName,
|
||||
"bsDisplayName": l10n.bsDisplayName,
|
||||
"btsDisplayName": l10n.btsDisplayName,
|
||||
"btxDisplayName": l10n.btxDisplayName,
|
||||
"buaDisplayName": l10n.buaDisplayName,
|
||||
"caDisplayName": l10n.caDisplayName,
|
||||
"cebDisplayName": l10n.cebDisplayName,
|
||||
"cggDisplayName": l10n.cggDisplayName,
|
||||
"chmDisplayName": l10n.chmDisplayName,
|
||||
"ckbDisplayName": l10n.ckbDisplayName,
|
||||
"cnhDisplayName": l10n.cnhDisplayName,
|
||||
"coDisplayName": l10n.coDisplayName,
|
||||
"crhDisplayName": l10n.crhDisplayName,
|
||||
"crsDisplayName": l10n.crsDisplayName,
|
||||
"csDisplayName": l10n.csDisplayName,
|
||||
"cvDisplayName": l10n.cvDisplayName,
|
||||
"cyDisplayName": l10n.cyDisplayName,
|
||||
"daDisplayName": l10n.daDisplayName,
|
||||
"deDisplayName": l10n.deDisplayName,
|
||||
"dinDisplayName": l10n.dinDisplayName,
|
||||
"doiDisplayName": l10n.doiDisplayName,
|
||||
"dovDisplayName": l10n.dovDisplayName,
|
||||
"dzDisplayName": l10n.dzDisplayName,
|
||||
"eeDisplayName": l10n.eeDisplayName,
|
||||
"enDisplayName": l10n.enDisplayName,
|
||||
"enAUDisplayName": l10n.enAUDisplayName,
|
||||
"enGBDisplayName": l10n.enGBDisplayName,
|
||||
"enINDisplayName": l10n.enINDisplayName,
|
||||
"enUSDisplayName": l10n.enUSDisplayName,
|
||||
"eoDisplayName": l10n.eoDisplayName,
|
||||
"esDisplayName": l10n.esDisplayName,
|
||||
"esESDisplayName": l10n.esESDisplayName,
|
||||
"esMXDisplayName": l10n.esMXDisplayName,
|
||||
"euDisplayName": l10n.euDisplayName,
|
||||
"faDisplayName": l10n.faDisplayName,
|
||||
"ffDisplayName": l10n.ffDisplayName,
|
||||
"fiDisplayName": l10n.fiDisplayName,
|
||||
"filDisplayName": l10n.filDisplayName,
|
||||
"fjDisplayName": l10n.fjDisplayName,
|
||||
"foDisplayName": l10n.foDisplayName,
|
||||
"frDisplayName": l10n.frDisplayName,
|
||||
"frCADisplayName": l10n.frCADisplayName,
|
||||
"frFRDisplayName": l10n.frFRDisplayName,
|
||||
"fyDisplayName": l10n.fyDisplayName,
|
||||
"gaDisplayName": l10n.gaDisplayName,
|
||||
"gaaDisplayName": l10n.gaaDisplayName,
|
||||
"gdDisplayName": l10n.gdDisplayName,
|
||||
"glDisplayName": l10n.glDisplayName,
|
||||
"gnDisplayName": l10n.gnDisplayName,
|
||||
"gomDisplayName": l10n.gomDisplayName,
|
||||
"guDisplayName": l10n.guDisplayName,
|
||||
"haDisplayName": l10n.haDisplayName,
|
||||
"hawDisplayName": l10n.hawDisplayName,
|
||||
"heDisplayName": l10n.heDisplayName,
|
||||
"hiDisplayName": l10n.hiDisplayName,
|
||||
"hilDisplayName": l10n.hilDisplayName,
|
||||
"hmnDisplayName": l10n.hmnDisplayName,
|
||||
"hneDisplayName": l10n.hneDisplayName,
|
||||
"hrDisplayName": l10n.hrDisplayName,
|
||||
"hrxDisplayName": l10n.hrxDisplayName,
|
||||
"htDisplayName": l10n.htDisplayName,
|
||||
"huDisplayName": l10n.huDisplayName,
|
||||
"hyDisplayName": l10n.hyDisplayName,
|
||||
"idDisplayName": l10n.idDisplayName,
|
||||
"igDisplayName": l10n.igDisplayName,
|
||||
"iloDisplayName": l10n.iloDisplayName,
|
||||
"isDisplayName": l10n.isDisplayName,
|
||||
"itDisplayName": l10n.itDisplayName,
|
||||
"jaDisplayName": l10n.jaDisplayName,
|
||||
"jvDisplayName": l10n.jvDisplayName,
|
||||
"kaDisplayName": l10n.kaDisplayName,
|
||||
"kkDisplayName": l10n.kkDisplayName,
|
||||
"kmDisplayName": l10n.kmDisplayName,
|
||||
"knDisplayName": l10n.knDisplayName,
|
||||
"koDisplayName": l10n.koDisplayName,
|
||||
"kokDisplayName": l10n.kokDisplayName,
|
||||
"kriDisplayName": l10n.kriDisplayName,
|
||||
"ksDisplayName": l10n.ksDisplayName,
|
||||
"ktuDisplayName": l10n.ktuDisplayName,
|
||||
"kuDisplayName": l10n.kuDisplayName,
|
||||
"kyDisplayName": l10n.kyDisplayName,
|
||||
"laDisplayName": l10n.laDisplayName,
|
||||
"lbDisplayName": l10n.lbDisplayName,
|
||||
"lgDisplayName": l10n.lgDisplayName,
|
||||
"liDisplayName": l10n.liDisplayName,
|
||||
"lijDisplayName": l10n.lijDisplayName,
|
||||
"lmoDisplayName": l10n.lmoDisplayName,
|
||||
"lnDisplayName": l10n.lnDisplayName,
|
||||
"loDisplayName": l10n.loDisplayName,
|
||||
"ltDisplayName": l10n.ltDisplayName,
|
||||
"ltgDisplayName": l10n.ltgDisplayName,
|
||||
"luoDisplayName": l10n.luoDisplayName,
|
||||
"lusDisplayName": l10n.lusDisplayName,
|
||||
"lvDisplayName": l10n.lvDisplayName,
|
||||
"maiDisplayName": l10n.maiDisplayName,
|
||||
"makDisplayName": l10n.makDisplayName,
|
||||
"mgDisplayName": l10n.mgDisplayName,
|
||||
"miDisplayName": l10n.miDisplayName,
|
||||
"minDisplayName": l10n.minDisplayName,
|
||||
"mkDisplayName": l10n.mkDisplayName,
|
||||
"mlDisplayName": l10n.mlDisplayName,
|
||||
"mnDisplayName": l10n.mnDisplayName,
|
||||
"mniDisplayName": l10n.mniDisplayName,
|
||||
"mrDisplayName": l10n.mrDisplayName,
|
||||
"msDisplayName": l10n.msDisplayName,
|
||||
"msArabDisplayName": l10n.msArabDisplayName,
|
||||
"msMYDisplayName": l10n.msMYDisplayName,
|
||||
"mtDisplayName": l10n.mtDisplayName,
|
||||
"mwrDisplayName": l10n.mwrDisplayName,
|
||||
"myDisplayName": l10n.myDisplayName,
|
||||
"nanDisplayName": l10n.nanDisplayName,
|
||||
"nbDisplayName": l10n.nbDisplayName,
|
||||
"neDisplayName": l10n.neDisplayName,
|
||||
"newDisplayName": l10n.newDisplayName,
|
||||
"nlDisplayName": l10n.nlDisplayName,
|
||||
"nlBEDisplayName": l10n.nlBEDisplayName,
|
||||
"noDisplayName": l10n.noDisplayName,
|
||||
"nrDisplayName": l10n.nrDisplayName,
|
||||
"nsoDisplayName": l10n.nsoDisplayName,
|
||||
"nusDisplayName": l10n.nusDisplayName,
|
||||
"nyDisplayName": l10n.nyDisplayName,
|
||||
"ocDisplayName": l10n.ocDisplayName,
|
||||
"omDisplayName": l10n.omDisplayName,
|
||||
"orDisplayName": l10n.orDisplayName,
|
||||
"paDisplayName": l10n.paDisplayName,
|
||||
"paArabDisplayName": l10n.paArabDisplayName,
|
||||
"paINDisplayName": l10n.paINDisplayName,
|
||||
"pagDisplayName": l10n.pagDisplayName,
|
||||
"pamDisplayName": l10n.pamDisplayName,
|
||||
"papDisplayName": l10n.papDisplayName,
|
||||
"plDisplayName": l10n.plDisplayName,
|
||||
"psDisplayName": l10n.psDisplayName,
|
||||
"ptDisplayName": l10n.ptDisplayName,
|
||||
"ptBRDisplayName": l10n.ptBRDisplayName,
|
||||
"ptPTDisplayName": l10n.ptPTDisplayName,
|
||||
"quDisplayName": l10n.quDisplayName,
|
||||
"rajDisplayName": l10n.rajDisplayName,
|
||||
"rnDisplayName": l10n.rnDisplayName,
|
||||
"roDisplayName": l10n.roDisplayName,
|
||||
"roMDDisplayName": l10n.roMDDisplayName,
|
||||
"romDisplayName": l10n.romDisplayName,
|
||||
"ruDisplayName": l10n.ruDisplayName,
|
||||
"rwDisplayName": l10n.rwDisplayName,
|
||||
"saDisplayName": l10n.saDisplayName,
|
||||
"satDisplayName": l10n.satDisplayName,
|
||||
"scnDisplayName": l10n.scnDisplayName,
|
||||
"sdDisplayName": l10n.sdDisplayName,
|
||||
"sgDisplayName": l10n.sgDisplayName,
|
||||
"shnDisplayName": l10n.shnDisplayName,
|
||||
"siDisplayName": l10n.siDisplayName,
|
||||
"skDisplayName": l10n.skDisplayName,
|
||||
"slDisplayName": l10n.slDisplayName,
|
||||
"smDisplayName": l10n.smDisplayName,
|
||||
"snDisplayName": l10n.snDisplayName,
|
||||
"soDisplayName": l10n.soDisplayName,
|
||||
"sqDisplayName": l10n.sqDisplayName,
|
||||
"srDisplayName": l10n.srDisplayName,
|
||||
"srMEDisplayName": l10n.srMEDisplayName,
|
||||
"ssDisplayName": l10n.ssDisplayName,
|
||||
"stDisplayName": l10n.stDisplayName,
|
||||
"suDisplayName": l10n.suDisplayName,
|
||||
"svDisplayName": l10n.svDisplayName,
|
||||
"swDisplayName": l10n.swDisplayName,
|
||||
"szlDisplayName": l10n.szlDisplayName,
|
||||
"taDisplayName": l10n.taDisplayName,
|
||||
"teDisplayName": l10n.teDisplayName,
|
||||
"tetDisplayName": l10n.tetDisplayName,
|
||||
"tgDisplayName": l10n.tgDisplayName,
|
||||
"thDisplayName": l10n.thDisplayName,
|
||||
"tiDisplayName": l10n.tiDisplayName,
|
||||
"tkDisplayName": l10n.tkDisplayName,
|
||||
"tlDisplayName": l10n.tlDisplayName,
|
||||
"tnDisplayName": l10n.tnDisplayName,
|
||||
"trDisplayName": l10n.trDisplayName,
|
||||
"tsDisplayName": l10n.tsDisplayName,
|
||||
"ttDisplayName": l10n.ttDisplayName,
|
||||
"ugDisplayName": l10n.ugDisplayName,
|
||||
"ukDisplayName": l10n.ukDisplayName,
|
||||
"urDisplayName": l10n.urDisplayName,
|
||||
"urINDisplayName": l10n.urINDisplayName,
|
||||
"urPKDisplayName": l10n.urPKDisplayName,
|
||||
"uzDisplayName": l10n.uzDisplayName,
|
||||
"viDisplayName": l10n.viDisplayName,
|
||||
"wuuDisplayName": l10n.wuuDisplayName,
|
||||
"xhDisplayName": l10n.xhDisplayName,
|
||||
"yiDisplayName": l10n.yiDisplayName,
|
||||
"yoDisplayName": l10n.yoDisplayName,
|
||||
"yuaDisplayName": l10n.yuaDisplayName,
|
||||
"yueDisplayName": l10n.yueDisplayName,
|
||||
"yueCNDisplayName": l10n.yueCNDisplayName,
|
||||
"yueHKDisplayName": l10n.yueHKDisplayName,
|
||||
"zhDisplayName": l10n.zhDisplayName,
|
||||
"zhCNDisplayName": l10n.zhCNDisplayName,
|
||||
"zhTWDisplayName": l10n.zhTWDisplayName,
|
||||
"zuDisplayName": l10n.zuDisplayName,
|
||||
};
|
||||
|
||||
return displayNameMap[langKey] ?? displayName;
|
||||
}
|
||||
|
||||
String get langCodeShort => langCode.split('-').first;
|
||||
|
|
@ -86,6 +312,21 @@ class LanguageModel {
|
|||
return _textDirection ?? _defaultTextDirection;
|
||||
}
|
||||
|
||||
static bool search(
|
||||
LanguageModel? item,
|
||||
String searchValue,
|
||||
BuildContext context,
|
||||
) {
|
||||
if (item == null) return false;
|
||||
final search = searchValue.toLowerCase();
|
||||
final displayName = item.displayName.toLowerCase();
|
||||
final displayNameLocal = item.getDisplayName(context).toLowerCase();
|
||||
final langCode = item.langCode.toLowerCase();
|
||||
return displayName.startsWith(search) ||
|
||||
displayNameLocal.startsWith(search) ||
|
||||
langCode.startsWith(search);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (other is LanguageModel) {
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ class PLanguageDropdownState extends State<PLanguageDropdown> {
|
|||
final bool aIsPriority = languagePriority.contains(a.langCode);
|
||||
final bool bIsPriority = languagePriority.contains(b.langCode);
|
||||
if (!aIsPriority && !bIsPriority) {
|
||||
return a.getDisplayName(context)!.compareTo(b.getDisplayName(context)!);
|
||||
return a.getDisplayName(context).compareTo(b.getDisplayName(context));
|
||||
}
|
||||
|
||||
if (aIsPriority && bIsPriority) {
|
||||
|
|
@ -158,13 +158,11 @@ class PLanguageDropdownState extends State<PLanguageDropdown> {
|
|||
),
|
||||
),
|
||||
),
|
||||
searchMatchFn: (item, searchValue) {
|
||||
final displayName = item.value?.displayName.toLowerCase();
|
||||
if (displayName == null) return false;
|
||||
|
||||
final search = searchValue.toLowerCase();
|
||||
return displayName.startsWith(search);
|
||||
},
|
||||
searchMatchFn: (item, searchValue) => LanguageModel.search(
|
||||
item.value,
|
||||
searchValue,
|
||||
context,
|
||||
),
|
||||
),
|
||||
onMenuStateChange: (isOpen) {
|
||||
if (!isOpen) _searchController.clear();
|
||||
|
|
@ -222,7 +220,7 @@ class LanguageDropDownEntry extends StatelessWidget {
|
|||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
languageModel.getDisplayName(context) ?? "",
|
||||
languageModel.getDisplayName(context),
|
||||
style: const TextStyle().copyWith(
|
||||
color: enabled
|
||||
? Theme.of(context).textTheme.bodyLarge!.color
|
||||
|
|
|
|||
|
|
@ -144,7 +144,8 @@ class LanguageSelectionPageState extends State<LanguageSelectionPage> {
|
|||
alignment: WrapAlignment.center,
|
||||
children: languages
|
||||
.where(
|
||||
(l) => l.displayName
|
||||
(l) => l
|
||||
.getDisplayName(context)
|
||||
.toLowerCase()
|
||||
.contains(
|
||||
_searchController.text
|
||||
|
|
@ -163,8 +164,7 @@ class LanguageSelectionPageState extends State<LanguageSelectionPage> {
|
|||
vertical: 4.0,
|
||||
),
|
||||
label: Text(
|
||||
l.getDisplayName(context) ??
|
||||
l.displayName,
|
||||
l.getDisplayName(context),
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
onSelected: (selected) {
|
||||
|
|
|
|||
|
|
@ -1,19 +1,21 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:fluffychat/config/themes.dart';
|
||||
import 'package:fluffychat/pangea/events/models/pangea_token_model.dart';
|
||||
import 'package:fluffychat/pangea/toolbar/widgets/select_mode_buttons.dart';
|
||||
import 'package:fluffychat/widgets/matrix.dart';
|
||||
|
||||
class TokenEmojiButton extends StatefulWidget {
|
||||
final PangeaToken? token;
|
||||
final String eventId;
|
||||
final ValueNotifier<SelectMode?> selectModeNotifier;
|
||||
final bool enabled;
|
||||
final String? emoji;
|
||||
final String? targetId;
|
||||
final VoidCallback? onSelect;
|
||||
|
||||
const TokenEmojiButton({
|
||||
super.key,
|
||||
required this.token,
|
||||
required this.eventId,
|
||||
required this.selectModeNotifier,
|
||||
this.enabled = true,
|
||||
this.emoji,
|
||||
this.targetId,
|
||||
this.onSelect,
|
||||
});
|
||||
|
|
@ -25,12 +27,30 @@ class TokenEmojiButton extends StatefulWidget {
|
|||
class TokenEmojiButtonState extends State<TokenEmojiButton>
|
||||
with TickerProviderStateMixin {
|
||||
final double buttonSize = 20.0;
|
||||
SelectMode? _prevMode;
|
||||
AnimationController? _controller;
|
||||
Animation<double>? _sizeAnimation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initAnimation();
|
||||
_prevMode = widget.selectModeNotifier.value;
|
||||
widget.selectModeNotifier.addListener(_onUpdateSelectMode);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller?.dispose();
|
||||
widget.selectModeNotifier.removeListener(_onUpdateSelectMode);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _initAnimation() {
|
||||
if (MatrixState.pangeaController.subscriptionController.isSubscribed ==
|
||||
false) {
|
||||
return;
|
||||
}
|
||||
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
|
|
@ -41,60 +61,72 @@ class TokenEmojiButtonState extends State<TokenEmojiButton>
|
|||
begin: 0,
|
||||
end: buttonSize,
|
||||
).animate(CurvedAnimation(parent: _controller!, curve: Curves.easeOut));
|
||||
|
||||
_controller?.forward();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller?.dispose();
|
||||
super.dispose();
|
||||
void _onUpdateSelectMode() {
|
||||
final mode = widget.selectModeNotifier.value;
|
||||
if (_prevMode != SelectMode.emoji && mode == SelectMode.emoji) {
|
||||
_controller?.forward();
|
||||
} else if (_prevMode == SelectMode.emoji && mode != SelectMode.emoji) {
|
||||
_controller?.reverse();
|
||||
}
|
||||
_prevMode = mode;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final eligible = widget.token?.lemma.saveVocab ?? false;
|
||||
final emoji = widget.token?.vocabConstructID.userSetEmoji.firstOrNull;
|
||||
if (_sizeAnimation != null) {
|
||||
final content = AnimatedBuilder(
|
||||
key: widget.targetId != null
|
||||
? MatrixState.pAnyState.layerLinkAndKey(widget.targetId!).key
|
||||
: null,
|
||||
animation: _sizeAnimation!,
|
||||
builder: (context, child) {
|
||||
return Container(
|
||||
height: _sizeAnimation!.value,
|
||||
width: eligible ? _sizeAnimation!.value : 0,
|
||||
alignment: Alignment.center,
|
||||
child: eligible
|
||||
? InkWell(
|
||||
onTap: widget.onSelect,
|
||||
borderRadius: BorderRadius.circular(99.0),
|
||||
child: emoji != null
|
||||
? Text(
|
||||
emoji,
|
||||
style: TextStyle(fontSize: buttonSize - 4.0),
|
||||
textScaler: TextScaler.noScaling,
|
||||
)
|
||||
: Icon(
|
||||
Icons.add_reaction_outlined,
|
||||
size: buttonSize - 4.0,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
);
|
||||
},
|
||||
);
|
||||
return widget.targetId != null
|
||||
? CompositedTransformTarget(
|
||||
link:
|
||||
MatrixState.pAnyState.layerLinkAndKey(widget.targetId!).link,
|
||||
child: content,
|
||||
)
|
||||
: content;
|
||||
if (_sizeAnimation == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return const SizedBox.shrink();
|
||||
final child = widget.enabled
|
||||
? InkWell(
|
||||
onTap: widget.onSelect,
|
||||
borderRadius: BorderRadius.circular(99.0),
|
||||
child: widget.emoji != null
|
||||
? Text(
|
||||
widget.emoji!,
|
||||
style: TextStyle(fontSize: buttonSize - 4.0),
|
||||
textScaler: TextScaler.noScaling,
|
||||
)
|
||||
: Icon(
|
||||
Icons.add_reaction_outlined,
|
||||
size: buttonSize - 4.0,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
)
|
||||
: null;
|
||||
|
||||
final content = ValueListenableBuilder(
|
||||
valueListenable: widget.selectModeNotifier,
|
||||
builder: (context, mode, __) {
|
||||
return mode == SelectMode.emoji
|
||||
? AnimatedBuilder(
|
||||
key: widget.targetId != null
|
||||
? MatrixState.pAnyState
|
||||
.layerLinkAndKey(widget.targetId!)
|
||||
.key
|
||||
: null,
|
||||
animation: _sizeAnimation!,
|
||||
child: child,
|
||||
builder: (context, child) {
|
||||
return Container(
|
||||
height: _sizeAnimation!.value,
|
||||
width: widget.enabled ? _sizeAnimation!.value : 0,
|
||||
alignment: Alignment.center,
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
)
|
||||
: const SizedBox();
|
||||
},
|
||||
);
|
||||
|
||||
return widget.targetId != null
|
||||
? CompositedTransformTarget(
|
||||
link: MatrixState.pAnyState.layerLinkAndKey(widget.targetId!).link,
|
||||
child: content,
|
||||
)
|
||||
: content;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,8 +22,6 @@ class PhoneticTranscriptionWidget extends StatefulWidget {
|
|||
final double? iconSize;
|
||||
final Color? iconColor;
|
||||
|
||||
final bool enabled;
|
||||
|
||||
final VoidCallback? onTranscriptionFetched;
|
||||
|
||||
const PhoneticTranscriptionWidget({
|
||||
|
|
@ -33,7 +31,6 @@ class PhoneticTranscriptionWidget extends StatefulWidget {
|
|||
this.style,
|
||||
this.iconSize,
|
||||
this.iconColor,
|
||||
this.enabled = true,
|
||||
this.onTranscriptionFetched,
|
||||
});
|
||||
|
||||
|
|
@ -141,86 +138,79 @@ class _PhoneticTranscriptionWidgetState
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IgnorePointer(
|
||||
ignoring: !widget.enabled,
|
||||
child: HoverBuilder(
|
||||
builder: (context, hovering) {
|
||||
return GestureDetector(
|
||||
onTap: _handleAudioTap,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
decoration: BoxDecoration(
|
||||
color: hovering
|
||||
? Colors.grey.withAlpha((0.2 * 255).round())
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
child: CompositedTransformTarget(
|
||||
link: MatrixState.pAnyState
|
||||
return HoverBuilder(
|
||||
builder: (context, hovering) {
|
||||
return GestureDetector(
|
||||
onTap: _handleAudioTap,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
decoration: BoxDecoration(
|
||||
color: hovering
|
||||
? Colors.grey.withAlpha((0.2 * 255).round())
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
child: CompositedTransformTarget(
|
||||
link: MatrixState.pAnyState
|
||||
.layerLinkAndKey("phonetic-transcription-${widget.text}")
|
||||
.link,
|
||||
child: Row(
|
||||
key: MatrixState.pAnyState
|
||||
.layerLinkAndKey("phonetic-transcription-${widget.text}")
|
||||
.link,
|
||||
child: Row(
|
||||
key: MatrixState.pAnyState
|
||||
.layerLinkAndKey("phonetic-transcription-${widget.text}")
|
||||
.key,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (_error != null)
|
||||
_error is UnsubscribedException
|
||||
? ErrorIndicator(
|
||||
message: L10n.of(context)
|
||||
.subscribeToUnlockTranscriptions,
|
||||
onTap: () {
|
||||
MatrixState
|
||||
.pangeaController.subscriptionController
|
||||
.showPaywall(context);
|
||||
},
|
||||
)
|
||||
: ErrorIndicator(
|
||||
message:
|
||||
L10n.of(context).failedToFetchTranscription,
|
||||
)
|
||||
else if (_isLoading || _transcription == null)
|
||||
const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator.adaptive(),
|
||||
)
|
||||
else
|
||||
Flexible(
|
||||
child: Text(
|
||||
_transcription!,
|
||||
textScaler: TextScaler.noScaling,
|
||||
style: widget.style ??
|
||||
Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
.key,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (_error != null)
|
||||
_error is UnsubscribedException
|
||||
? ErrorIndicator(
|
||||
message: L10n.of(context)
|
||||
.subscribeToUnlockTranscriptions,
|
||||
onTap: () {
|
||||
MatrixState
|
||||
.pangeaController.subscriptionController
|
||||
.showPaywall(context);
|
||||
},
|
||||
)
|
||||
: ErrorIndicator(
|
||||
message:
|
||||
L10n.of(context).failedToFetchTranscription,
|
||||
)
|
||||
else if (_isLoading || _transcription == null)
|
||||
const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator.adaptive(),
|
||||
)
|
||||
else
|
||||
Flexible(
|
||||
child: Text(
|
||||
_transcription!,
|
||||
textScaler: TextScaler.noScaling,
|
||||
style: widget.style ??
|
||||
Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
if (_transcription != null &&
|
||||
_error == null &&
|
||||
widget.enabled)
|
||||
const SizedBox(width: 8),
|
||||
if (_transcription != null &&
|
||||
_error == null &&
|
||||
widget.enabled)
|
||||
Tooltip(
|
||||
message: _isPlaying
|
||||
? L10n.of(context).stop
|
||||
: L10n.of(context).playAudio,
|
||||
child: Icon(
|
||||
_isPlaying ? Icons.pause_outlined : Icons.volume_up,
|
||||
size: widget.iconSize ?? 24,
|
||||
color: widget.iconColor ??
|
||||
Theme.of(context).iconTheme.color,
|
||||
),
|
||||
),
|
||||
if (_transcription != null && _error == null)
|
||||
const SizedBox(width: 8),
|
||||
if (_transcription != null && _error == null)
|
||||
Tooltip(
|
||||
message: _isPlaying
|
||||
? L10n.of(context).stop
|
||||
: L10n.of(context).playAudio,
|
||||
child: Icon(
|
||||
_isPlaying ? Icons.pause_outlined : Icons.volume_up,
|
||||
size: widget.iconSize ?? 24,
|
||||
color: widget.iconColor ??
|
||||
Theme.of(context).iconTheme.color,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -154,8 +154,7 @@ class SpaceAnalyticsState extends State<SpaceAnalytics> {
|
|||
|
||||
List<LanguageModel> get availableLanguages => _langsToUsers.keys.toList()
|
||||
..sort(
|
||||
(a, b) => (a.getDisplayName(context) ?? a.displayName)
|
||||
.compareTo(b.getDisplayName(context) ?? b.displayName),
|
||||
(a, b) => a.getDisplayName(context).compareTo(b.getDisplayName(context)),
|
||||
);
|
||||
|
||||
int get completedDownloads =>
|
||||
|
|
|
|||
|
|
@ -109,9 +109,7 @@ class SpaceAnalyticsView extends StatelessWidget {
|
|||
? controller.selectedLanguage!.langCode
|
||||
.toUpperCase()
|
||||
: controller.selectedLanguage!
|
||||
.getDisplayName(context) ??
|
||||
controller
|
||||
.selectedLanguage!.displayName,
|
||||
.getDisplayName(context),
|
||||
style: TextStyle(
|
||||
color:
|
||||
theme.colorScheme.onPrimaryContainer,
|
||||
|
|
@ -132,8 +130,7 @@ class SpaceAnalyticsView extends StatelessWidget {
|
|||
(item) => DropdownMenuItem(
|
||||
value: item,
|
||||
child: DropdownTextButton(
|
||||
text: item.getDisplayName(context) ??
|
||||
item.displayName,
|
||||
text: item.getDisplayName(context),
|
||||
isSelected: false,
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter/scheduler.dart';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
import 'package:matrix/matrix.dart' hide Result;
|
||||
|
||||
import 'package:fluffychat/config/app_config.dart';
|
||||
import 'package:fluffychat/pages/chat/chat.dart';
|
||||
|
|
@ -32,10 +32,10 @@ import 'package:fluffychat/pangea/toolbar/controllers/text_to_speech_controller.
|
|||
import 'package:fluffychat/pangea/toolbar/controllers/tts_controller.dart';
|
||||
import 'package:fluffychat/pangea/toolbar/enums/message_mode_enum.dart';
|
||||
import 'package:fluffychat/pangea/toolbar/enums/reading_assistance_mode_enum.dart';
|
||||
import 'package:fluffychat/pangea/toolbar/models/speech_to_text_models.dart';
|
||||
import 'package:fluffychat/pangea/toolbar/reading_assistance_input_row/morph_selection.dart';
|
||||
import 'package:fluffychat/pangea/toolbar/widgets/message_selection_positioner.dart';
|
||||
import 'package:fluffychat/pangea/toolbar/widgets/select_mode_buttons.dart';
|
||||
import 'package:fluffychat/pangea/toolbar/widgets/select_mode_controller.dart';
|
||||
import 'package:fluffychat/pangea/toolbar/widgets/word_zoom/lemma_meaning_builder.dart';
|
||||
import 'package:fluffychat/widgets/future_loading_dialog.dart';
|
||||
import 'package:fluffychat/widgets/matrix.dart';
|
||||
|
|
@ -92,20 +92,11 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
|
|||
|
||||
ReadingAssistanceMode? readingAssistanceMode; // default mode
|
||||
|
||||
SpeechToTextModel? transcription;
|
||||
String? transcriptionError;
|
||||
|
||||
bool showTranslation = false;
|
||||
String? translation;
|
||||
|
||||
bool showSpeechTranslation = false;
|
||||
String? speechTranslation;
|
||||
|
||||
final StreamController contentChangedStream = StreamController.broadcast();
|
||||
|
||||
double maxWidth = AppConfig.toolbarMinWidth;
|
||||
|
||||
SelectMode? selectedMode;
|
||||
late SelectModeController selectModeController;
|
||||
ValueNotifier<SelectMode?> get selectedMode =>
|
||||
selectModeController.selectedMode;
|
||||
|
||||
/////////////////////////////////////
|
||||
/// Lifecycle
|
||||
|
|
@ -114,6 +105,7 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
|
|||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
selectModeController = SelectModeController(pangeaMessageEvent);
|
||||
initializeTokensAndMode();
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) => widget.chatController.setSelectedEvent(event),
|
||||
|
|
@ -125,7 +117,7 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
|
|||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) => widget.chatController.clearSelectedEvents(),
|
||||
);
|
||||
contentChangedStream.close();
|
||||
selectModeController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
|
|
@ -257,13 +249,18 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
|
|||
|
||||
/// Update [selectedSpan]
|
||||
void updateSelectedSpan(PangeaTokenText? selectedSpan) {
|
||||
if (MatrixState.pangeaController.subscriptionController.isSubscribed ==
|
||||
false) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedSpan == _selectedSpan) return;
|
||||
if (selectedMorph != null) {
|
||||
selectedMorph = null;
|
||||
}
|
||||
|
||||
_selectedSpan = selectedSpan;
|
||||
if (selectedMode == SelectMode.emoji && selectedToken != null) {
|
||||
if (selectedMode.value == SelectMode.emoji && selectedToken != null) {
|
||||
showTokenEmojiPopup(selectedToken!);
|
||||
}
|
||||
if (mounted) {
|
||||
|
|
@ -379,12 +376,6 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
|
|||
?.firstWhereOrNull(isTokenSelected);
|
||||
}
|
||||
|
||||
bool get showingExtraContent =>
|
||||
(showTranslation && translation != null) ||
|
||||
(showSpeechTranslation && speechTranslation != null) ||
|
||||
transcription != null ||
|
||||
transcriptionError != null;
|
||||
|
||||
bool get showLanguageAssistance {
|
||||
if (!event.status.isSent || event.type != EventTypes.Message) {
|
||||
return false;
|
||||
|
|
@ -532,75 +523,6 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
|
|||
);
|
||||
}
|
||||
|
||||
void setSelectMode(SelectMode? mode) {
|
||||
if (!mounted) return;
|
||||
if (selectedMode == mode) return;
|
||||
setState(() => selectedMode = mode);
|
||||
}
|
||||
|
||||
void setTranslation(String value) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
translation = value;
|
||||
contentChangedStream.add(true);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void setShowTranslation(bool show) {
|
||||
if (!mounted) return;
|
||||
if (translation == null) {
|
||||
setState(() => showTranslation = false);
|
||||
}
|
||||
|
||||
if (showTranslation == show) return;
|
||||
setState(() {
|
||||
showTranslation = show;
|
||||
contentChangedStream.add(true);
|
||||
});
|
||||
}
|
||||
|
||||
void setSpeechTranslation(String value) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
speechTranslation = value;
|
||||
contentChangedStream.add(true);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void setShowSpeechTranslation(bool show) {
|
||||
if (!mounted) return;
|
||||
if (speechTranslation == null) {
|
||||
setState(() => showSpeechTranslation = false);
|
||||
}
|
||||
|
||||
if (showSpeechTranslation == show) return;
|
||||
setState(() {
|
||||
showSpeechTranslation = show;
|
||||
contentChangedStream.add(true);
|
||||
});
|
||||
}
|
||||
|
||||
void setTranscription(SpeechToTextModel value) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
transcriptionError = null;
|
||||
transcription = value;
|
||||
contentChangedStream.add(true);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void setTranscriptionError(String value) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
transcriptionError = value;
|
||||
contentChangedStream.add(true);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void showTokenEmojiPopup(
|
||||
PangeaToken token,
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -100,7 +100,7 @@ class MessageSelectionPositionerState extends State<MessageSelectionPositioner>
|
|||
).listen((_) => setState(() {}));
|
||||
|
||||
_contentChangedSubscription = widget
|
||||
.overlayController.contentChangedStream.stream
|
||||
.overlayController.selectModeController.contentChangedStream.stream
|
||||
.listen(_onContentSizeChanged);
|
||||
}
|
||||
|
||||
|
|
@ -370,7 +370,12 @@ class MessageSelectionPositionerState extends State<MessageSelectionPositioner>
|
|||
}
|
||||
}
|
||||
|
||||
void setReadingAssistanceMode(ReadingAssistanceMode mode) {
|
||||
void launchPractice(ReadingAssistanceMode mode) {
|
||||
if (MatrixState.pangeaController.subscriptionController.isSubscribed ==
|
||||
false) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
setState(() => readingAssistanceMode = mode);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,36 +48,40 @@ class OverMessageOverlay extends StatelessWidget {
|
|||
'overlay_message_${controller.widget.event.eventId}',
|
||||
)
|
||||
.link,
|
||||
child: OverlayCenterContent(
|
||||
event: controller.widget.event,
|
||||
messageHeight:
|
||||
controller.widget.overlayController.selectedMode !=
|
||||
SelectMode.emoji
|
||||
child: ValueListenableBuilder(
|
||||
valueListenable:
|
||||
controller.widget.overlayController.selectedMode,
|
||||
builder: (context, mode, __) {
|
||||
return OverlayCenterContent(
|
||||
event: controller.widget.event,
|
||||
messageHeight: mode != SelectMode.emoji
|
||||
? controller.originalMessageSize.height
|
||||
: null,
|
||||
messageWidth:
|
||||
controller.widget.overlayController.showingExtraContent
|
||||
messageWidth: controller.widget.overlayController
|
||||
.selectModeController.showingExtraContent
|
||||
? max(controller.originalMessageSize.width, 150)
|
||||
: controller.originalMessageSize.width,
|
||||
overlayController: controller.widget.overlayController,
|
||||
chatController: controller.widget.chatController,
|
||||
nextEvent: controller.widget.nextEvent,
|
||||
prevEvent: controller.widget.prevEvent,
|
||||
hasReactions: controller.hasReactions,
|
||||
isTransitionAnimation: true,
|
||||
readingAssistanceMode: controller.readingAssistanceMode,
|
||||
overlayKey: MatrixState.pAnyState
|
||||
.layerLinkAndKey(
|
||||
'overlay_message_${controller.widget.event.eventId}',
|
||||
)
|
||||
.key,
|
||||
overlayController: controller.widget.overlayController,
|
||||
chatController: controller.widget.chatController,
|
||||
nextEvent: controller.widget.nextEvent,
|
||||
prevEvent: controller.widget.prevEvent,
|
||||
hasReactions: controller.hasReactions,
|
||||
isTransitionAnimation: true,
|
||||
readingAssistanceMode: controller.readingAssistanceMode,
|
||||
overlayKey: MatrixState.pAnyState
|
||||
.layerLinkAndKey(
|
||||
'overlay_message_${controller.widget.event.eventId}',
|
||||
)
|
||||
.key,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4.0),
|
||||
SelectModeButtons(
|
||||
controller: controller.widget.chatController,
|
||||
overlayController: controller.widget.overlayController,
|
||||
lauchPractice: () => controller.setReadingAssistanceMode(
|
||||
launchPractice: () => controller.launchPractice(
|
||||
ReadingAssistanceMode.practiceMode,
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -10,20 +10,23 @@ import 'package:fluffychat/l10n/l10n.dart';
|
|||
import 'package:fluffychat/pages/chat/chat.dart';
|
||||
import 'package:fluffychat/pages/chat/events/message_content.dart';
|
||||
import 'package:fluffychat/pages/chat/events/reply_content.dart';
|
||||
import 'package:fluffychat/pangea/bot/utils/bot_name.dart';
|
||||
import 'package:fluffychat/pangea/common/utils/async_state.dart';
|
||||
import 'package:fluffychat/pangea/common/widgets/error_indicator.dart';
|
||||
import 'package:fluffychat/pangea/events/extensions/pangea_event_extension.dart';
|
||||
import 'package:fluffychat/pangea/events/models/pangea_token_model.dart';
|
||||
import 'package:fluffychat/pangea/learning_settings/models/language_model.dart';
|
||||
import 'package:fluffychat/pangea/learning_settings/utils/p_language_store.dart';
|
||||
import 'package:fluffychat/pangea/phonetic_transcription/phonetic_transcription_widget.dart';
|
||||
import 'package:fluffychat/pangea/toolbar/enums/reading_assistance_mode_enum.dart';
|
||||
import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.dart';
|
||||
import 'package:fluffychat/pangea/toolbar/widgets/select_mode_buttons.dart';
|
||||
import 'package:fluffychat/pangea/toolbar/widgets/select_mode_controller.dart';
|
||||
import 'package:fluffychat/pangea/toolbar/widgets/stt_transcript_tokens.dart';
|
||||
import 'package:fluffychat/utils/date_time_extension.dart';
|
||||
import 'package:fluffychat/utils/file_description.dart';
|
||||
import 'package:fluffychat/widgets/avatar.dart';
|
||||
import 'package:fluffychat/widgets/matrix.dart';
|
||||
|
||||
// @ggurdin be great to explain the need/function of a widget like this
|
||||
class OverlayMessage extends StatelessWidget {
|
||||
final Event event;
|
||||
final MessageOverlayController overlayController;
|
||||
|
|
@ -137,144 +140,7 @@ class OverlayMessage extends StatelessWidget {
|
|||
final isSubscribed =
|
||||
MatrixState.pangeaController.subscriptionController.isSubscribed;
|
||||
|
||||
final showTranslation = overlayController.showTranslation &&
|
||||
overlayController.translation != null &&
|
||||
isSubscribed != false;
|
||||
|
||||
final showTranscription =
|
||||
overlayController.pangeaMessageEvent.isAudioMessage == true &&
|
||||
isSubscribed != false;
|
||||
|
||||
final showSpeechTranslation = overlayController.showSpeechTranslation &&
|
||||
overlayController.speechTranslation != null &&
|
||||
isSubscribed != false;
|
||||
|
||||
final transcription = showTranscription
|
||||
? Container(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: min(
|
||||
FluffyThemes.columnWidth * 1.5,
|
||||
MediaQuery.of(context).size.width -
|
||||
(ownMessage ? 0 : Avatar.defaultSize) -
|
||||
32.0 -
|
||||
(FluffyThemes.isColumnMode(context)
|
||||
? FluffyThemes.columnWidth + FluffyThemes.navRailWidth
|
||||
: 0.0),
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
child: overlayController.transcriptionError != null
|
||||
? Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
L10n.of(context).transcriptionFailed,
|
||||
textScaler: TextScaler.noScaling,
|
||||
style: AppConfig.messageTextStyle(
|
||||
event,
|
||||
textColor,
|
||||
).copyWith(fontStyle: FontStyle.italic),
|
||||
),
|
||||
],
|
||||
)
|
||||
: overlayController.transcription != null
|
||||
? SingleChildScrollView(
|
||||
child: Column(
|
||||
spacing: 8.0,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SttTranscriptTokens(
|
||||
model: overlayController.transcription!,
|
||||
style: AppConfig.messageTextStyle(
|
||||
event,
|
||||
textColor,
|
||||
).copyWith(
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
onClick: overlayController
|
||||
.onClickOverlayMessageToken,
|
||||
isSelected: overlayController.isTokenSelected,
|
||||
),
|
||||
if (MatrixState.pangeaController
|
||||
.languageController.showTranscription)
|
||||
PhoneticTranscriptionWidget(
|
||||
text: overlayController
|
||||
.transcription!.transcript.text,
|
||||
textLanguage: PLanguageStore.byLangCode(
|
||||
overlayController
|
||||
.transcription!.langCode,
|
||||
) ??
|
||||
LanguageModel.unknown,
|
||||
style: AppConfig.messageTextStyle(
|
||||
event,
|
||||
textColor,
|
||||
),
|
||||
iconColor: textColor,
|
||||
enabled:
|
||||
event.senderId != BotName.byEnvironment,
|
||||
onTranscriptionFetched: () =>
|
||||
overlayController.contentChangedStream
|
||||
.add(true),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CircularProgressIndicator.adaptive(
|
||||
backgroundColor: textColor,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
: const SizedBox();
|
||||
|
||||
final translation = showTranslation || showSpeechTranslation
|
||||
? Container(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: min(
|
||||
FluffyThemes.columnWidth * 1.5,
|
||||
MediaQuery.of(context).size.width -
|
||||
(ownMessage ? 0 : Avatar.defaultSize) -
|
||||
32.0 -
|
||||
(FluffyThemes.isColumnMode(context)
|
||||
? FluffyThemes.columnWidth + FluffyThemes.navRailWidth
|
||||
: 0.0),
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
12.0,
|
||||
20.0,
|
||||
12.0,
|
||||
12.0,
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: Text(
|
||||
showTranslation
|
||||
? overlayController.translation!
|
||||
: overlayController.speechTranslation!,
|
||||
textScaler: TextScaler.noScaling,
|
||||
style: AppConfig.messageTextStyle(
|
||||
event,
|
||||
textColor,
|
||||
).copyWith(
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: const SizedBox();
|
||||
final selectModeController = overlayController.selectModeController;
|
||||
|
||||
final content = Container(
|
||||
decoration: BoxDecoration(
|
||||
|
|
@ -392,6 +258,21 @@ class OverlayMessage extends StatelessWidget {
|
|||
),
|
||||
);
|
||||
|
||||
final maxWidth = min(
|
||||
FluffyThemes.columnWidth * 1.5,
|
||||
MediaQuery.of(context).size.width -
|
||||
(ownMessage ? 0 : Avatar.defaultSize) -
|
||||
32.0 -
|
||||
(FluffyThemes.isColumnMode(context)
|
||||
? FluffyThemes.columnWidth + FluffyThemes.navRailWidth
|
||||
: 0.0),
|
||||
);
|
||||
|
||||
final style = AppConfig.messageTextStyle(
|
||||
event,
|
||||
textColor,
|
||||
);
|
||||
|
||||
return Material(
|
||||
type: MaterialType.transparency,
|
||||
child: Container(
|
||||
|
|
@ -408,7 +289,16 @@ class OverlayMessage extends StatelessWidget {
|
|||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
transcription,
|
||||
_MessageBubbleTranscription(
|
||||
controller: selectModeController,
|
||||
enabled: event.messageType == MessageTypes.Audio &&
|
||||
!event.redacted &&
|
||||
isSubscribed != false,
|
||||
maxWidth: maxWidth,
|
||||
style: style,
|
||||
onTokenSelected: overlayController.onClickOverlayMessageToken,
|
||||
isTokenSelected: overlayController.isTokenSelected,
|
||||
),
|
||||
sizeAnimation != null
|
||||
? AnimatedBuilder(
|
||||
animation: sizeAnimation!,
|
||||
|
|
@ -421,7 +311,11 @@ class OverlayMessage extends StatelessWidget {
|
|||
},
|
||||
)
|
||||
: content,
|
||||
translation,
|
||||
_MessageSelectModeContent(
|
||||
controller: selectModeController,
|
||||
style: style,
|
||||
maxWidth: maxWidth,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -429,3 +323,196 @@ class OverlayMessage extends StatelessWidget {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MessageSelectModeContent extends StatelessWidget {
|
||||
final SelectModeController controller;
|
||||
final TextStyle style;
|
||||
final double maxWidth;
|
||||
|
||||
const _MessageSelectModeContent({
|
||||
required this.controller,
|
||||
required this.style,
|
||||
required this.maxWidth,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListenableBuilder(
|
||||
listenable: Listenable.merge(
|
||||
[
|
||||
controller.selectedMode,
|
||||
controller.currentModeStateNotifier,
|
||||
],
|
||||
),
|
||||
builder: (context, _) {
|
||||
final mode = controller.selectedMode.value;
|
||||
if (mode == null) {
|
||||
return const SizedBox();
|
||||
}
|
||||
|
||||
final sub = MatrixState.pangeaController.subscriptionController;
|
||||
if (sub.isSubscribed == false) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
child: ErrorIndicator(
|
||||
message: L10n.of(context).subscribeReadingAssistance,
|
||||
onTap: () => sub.showPaywall(context),
|
||||
style: style,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (![
|
||||
SelectMode.translate,
|
||||
SelectMode.speechTranslation,
|
||||
].contains(mode)) {
|
||||
return const SizedBox();
|
||||
}
|
||||
|
||||
final AsyncState<String> state = mode == SelectMode.translate
|
||||
? controller.translationState.value
|
||||
: controller.speechTranslationState.value;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
child: switch (state) {
|
||||
AsyncLoading() => Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CircularProgressIndicator.adaptive(
|
||||
backgroundColor: style.color,
|
||||
),
|
||||
],
|
||||
),
|
||||
AsyncError(error: final _) => Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
L10n.of(context).translationError,
|
||||
textScaler: TextScaler.noScaling,
|
||||
style: style.copyWith(fontStyle: FontStyle.italic),
|
||||
),
|
||||
],
|
||||
),
|
||||
AsyncLoaded(value: final value) => Container(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: maxWidth,
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: Text(
|
||||
value,
|
||||
textScaler: TextScaler.noScaling,
|
||||
style: style.copyWith(
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
_ => const SizedBox(),
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MessageBubbleTranscription extends StatelessWidget {
|
||||
final SelectModeController controller;
|
||||
final bool enabled;
|
||||
final double maxWidth;
|
||||
final TextStyle style;
|
||||
|
||||
final Function(PangeaToken) onTokenSelected;
|
||||
final bool Function(PangeaToken) isTokenSelected;
|
||||
|
||||
const _MessageBubbleTranscription({
|
||||
required this.controller,
|
||||
required this.enabled,
|
||||
required this.maxWidth,
|
||||
required this.style,
|
||||
required this.onTokenSelected,
|
||||
required this.isTokenSelected,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!enabled) {
|
||||
return const SizedBox();
|
||||
}
|
||||
|
||||
return Container(
|
||||
constraints: BoxConstraints(maxWidth: maxWidth),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
child: ValueListenableBuilder(
|
||||
valueListenable: controller.transcriptionState,
|
||||
builder: (context, transcriptionState, _) {
|
||||
switch (transcriptionState) {
|
||||
case AsyncLoading():
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CircularProgressIndicator.adaptive(
|
||||
backgroundColor: style.color,
|
||||
),
|
||||
],
|
||||
);
|
||||
case AsyncError(error: final _):
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
L10n.of(context).transcriptionFailed,
|
||||
textScaler: TextScaler.noScaling,
|
||||
style: style.copyWith(fontStyle: FontStyle.italic),
|
||||
),
|
||||
],
|
||||
);
|
||||
case AsyncLoaded(value: final transcription):
|
||||
return SingleChildScrollView(
|
||||
child: Column(
|
||||
spacing: 8.0,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SttTranscriptTokens(
|
||||
model: transcription,
|
||||
style: style.copyWith(fontStyle: FontStyle.italic),
|
||||
onClick: onTokenSelected,
|
||||
isSelected: isTokenSelected,
|
||||
),
|
||||
if (MatrixState.pangeaController.languageController
|
||||
.showTranscription)
|
||||
PhoneticTranscriptionWidget(
|
||||
text: transcription.transcript.text,
|
||||
textLanguage: PLanguageStore.byLangCode(
|
||||
transcription.langCode,
|
||||
) ??
|
||||
LanguageModel.unknown,
|
||||
style: style,
|
||||
iconColor: style.color,
|
||||
onTranscriptionFetched: () =>
|
||||
controller.contentChangedStream.add(true),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
default:
|
||||
return const SizedBox();
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,11 @@
|
|||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:just_audio/just_audio.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import 'package:fluffychat/config/app_config.dart';
|
||||
import 'package:fluffychat/config/themes.dart';
|
||||
|
|
@ -22,6 +20,7 @@ import 'package:fluffychat/pangea/events/utils/report_message.dart';
|
|||
import 'package:fluffychat/pangea/toolbar/controllers/tts_controller.dart';
|
||||
import 'package:fluffychat/pangea/toolbar/widgets/message_audio_card.dart';
|
||||
import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.dart';
|
||||
import 'package:fluffychat/pangea/toolbar/widgets/select_mode_controller.dart';
|
||||
import 'package:fluffychat/widgets/matrix.dart';
|
||||
|
||||
enum SelectMode {
|
||||
|
|
@ -125,12 +124,12 @@ enum MessageActions {
|
|||
}
|
||||
|
||||
class SelectModeButtons extends StatefulWidget {
|
||||
final VoidCallback lauchPractice;
|
||||
final VoidCallback launchPractice;
|
||||
final MessageOverlayController overlayController;
|
||||
final ChatController controller;
|
||||
|
||||
const SelectModeButtons({
|
||||
required this.lauchPractice,
|
||||
required this.launchPractice,
|
||||
required this.overlayController,
|
||||
required this.controller,
|
||||
super.key,
|
||||
|
|
@ -144,6 +143,9 @@ class SelectModeButtonsState extends State<SelectModeButtons> {
|
|||
static const double iconWidth = 36.0;
|
||||
static const double buttonSize = 40.0;
|
||||
|
||||
StreamSubscription? _playerStateSub;
|
||||
StreamSubscription? _audioSub;
|
||||
|
||||
static List<SelectMode> get textModes => [
|
||||
SelectMode.audio,
|
||||
SelectMode.translate,
|
||||
|
|
@ -155,33 +157,15 @@ class SelectModeButtonsState extends State<SelectModeButtons> {
|
|||
SelectMode.speechTranslation,
|
||||
];
|
||||
|
||||
bool _isLoadingAudio = false;
|
||||
PangeaAudioFile? _audioBytes;
|
||||
File? _audioFile;
|
||||
String? _audioError;
|
||||
|
||||
StreamSubscription? _onPlayerStateChanged;
|
||||
StreamSubscription? _onAudioPositionChanged;
|
||||
|
||||
bool _isLoadingTranslation = false;
|
||||
String? _translationError;
|
||||
|
||||
bool _isLoadingSpeechTranslation = false;
|
||||
String? _speechTranslationError;
|
||||
|
||||
Completer<String>? _transcriptionCompleter;
|
||||
|
||||
MatrixState? matrix;
|
||||
|
||||
SelectMode? get _selectedMode => widget.overlayController.selectedMode;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
matrix = Matrix.of(context);
|
||||
if (messageEvent?.isAudioMessage == true) {
|
||||
_fetchTranscription();
|
||||
if (messageEvent.isAudioMessage == true) {
|
||||
controller.fetchTranscription();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -190,159 +174,83 @@ class SelectModeButtonsState extends State<SelectModeButtons> {
|
|||
matrix?.audioPlayer?.dispose();
|
||||
matrix?.audioPlayer = null;
|
||||
matrix?.voiceMessageEventId.value = null;
|
||||
|
||||
_onPlayerStateChanged?.cancel();
|
||||
_onAudioPositionChanged?.cancel();
|
||||
_audioSub?.cancel();
|
||||
_playerStateSub?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
PangeaMessageEvent? get messageEvent =>
|
||||
PangeaMessageEvent get messageEvent =>
|
||||
widget.overlayController.pangeaMessageEvent;
|
||||
|
||||
String? get l1Code =>
|
||||
MatrixState.pangeaController.languageController.userL1?.langCodeShort;
|
||||
String? get l2Code =>
|
||||
MatrixState.pangeaController.languageController.userL2?.langCodeShort;
|
||||
|
||||
void _clear() {
|
||||
setState(() {
|
||||
// Audio errors do not go away when I switch modes and back
|
||||
// Is there any reason to wipe error records on clear?
|
||||
_translationError = null;
|
||||
_speechTranslationError = null;
|
||||
});
|
||||
|
||||
widget.overlayController.updateSelectedSpan(null);
|
||||
widget.overlayController.setShowTranslation(false);
|
||||
widget.overlayController.setShowSpeechTranslation(false);
|
||||
}
|
||||
|
||||
Future<void> _updateMode(SelectMode? mode) async {
|
||||
_clear();
|
||||
SelectModeController get controller =>
|
||||
widget.overlayController.selectModeController;
|
||||
|
||||
Future<void> updateMode(SelectMode? mode) async {
|
||||
if (mode == null) {
|
||||
matrix?.audioPlayer?.stop();
|
||||
matrix?.audioPlayer?.seek(null);
|
||||
widget.overlayController.setSelectMode(mode);
|
||||
controller.setSelectMode(mode);
|
||||
return;
|
||||
}
|
||||
|
||||
final selectedMode = _selectedMode == mode &&
|
||||
(mode != SelectMode.audio || _audioError != null)
|
||||
? null
|
||||
: mode;
|
||||
widget.overlayController.setSelectMode(selectedMode);
|
||||
final updatedMode =
|
||||
controller.selectedMode.value == mode && mode != SelectMode.audio
|
||||
? null
|
||||
: mode;
|
||||
controller.setSelectMode(updatedMode);
|
||||
|
||||
if (selectedMode == SelectMode.audio) {
|
||||
_playAudio();
|
||||
if (updatedMode == SelectMode.audio) {
|
||||
playAudio();
|
||||
return;
|
||||
} else {
|
||||
matrix?.audioPlayer?.stop();
|
||||
matrix?.audioPlayer?.seek(null);
|
||||
}
|
||||
|
||||
if (selectedMode == SelectMode.practice) {
|
||||
widget.lauchPractice();
|
||||
if (updatedMode == SelectMode.practice) {
|
||||
widget.launchPractice();
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedMode == SelectMode.translate) {
|
||||
await _fetchTranslation();
|
||||
widget.overlayController.setShowTranslation(true);
|
||||
if (updatedMode == SelectMode.translate) {
|
||||
await controller.fetchTranslation();
|
||||
}
|
||||
|
||||
if (selectedMode == SelectMode.speechTranslation) {
|
||||
await _fetchSpeechTranslation();
|
||||
widget.overlayController.setShowSpeechTranslation(true);
|
||||
if (updatedMode == SelectMode.speechTranslation) {
|
||||
await controller.fetchSpeechTranslation();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _fetchAudio() async {
|
||||
if (!mounted || messageEvent == null) return;
|
||||
setState(() => _isLoadingAudio = true);
|
||||
|
||||
try {
|
||||
final String langCode = messageEvent!.messageDisplayLangCode;
|
||||
final Event? localEvent = messageEvent!.getTextToSpeechLocal(
|
||||
langCode,
|
||||
messageEvent!.messageDisplayText,
|
||||
);
|
||||
|
||||
if (localEvent != null) {
|
||||
_audioBytes = await localEvent.getPangeaAudioFile();
|
||||
} else {
|
||||
_audioBytes = await messageEvent!.getMatrixAudioFile(
|
||||
langCode,
|
||||
);
|
||||
}
|
||||
|
||||
if (!kIsWeb) {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
|
||||
File? file;
|
||||
file = File('${tempDir.path}/${_audioBytes!.name}');
|
||||
await file.writeAsBytes(_audioBytes!.bytes);
|
||||
_audioFile = file;
|
||||
}
|
||||
} catch (e, s) {
|
||||
_audioError = e.toString();
|
||||
ErrorHandler.logError(
|
||||
e: e,
|
||||
s: s,
|
||||
m: 'something wrong getting audio in MessageAudioCardState',
|
||||
data: {
|
||||
'widget.messageEvent.messageDisplayLangCode':
|
||||
messageEvent?.messageDisplayLangCode,
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
if (mounted) setState(() => _isLoadingAudio = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _playAudio() async {
|
||||
final playerID =
|
||||
"${widget.overlayController.pangeaMessageEvent.eventId}_button";
|
||||
Future<void> playAudio() async {
|
||||
final playerID = "${messageEvent.eventId}_button";
|
||||
|
||||
if (matrix?.audioPlayer != null &&
|
||||
matrix?.voiceMessageEventId.value == playerID) {
|
||||
// If the audio player is already initialized and playing the same message, pause it
|
||||
if (matrix!.audioPlayer!.playerState.playing) {
|
||||
await matrix?.audioPlayer?.pause();
|
||||
await matrix!.audioPlayer!.pause();
|
||||
return;
|
||||
}
|
||||
// If the audio player is paused, resume it
|
||||
await matrix?.audioPlayer?.play();
|
||||
await matrix!.audioPlayer!.play();
|
||||
return;
|
||||
}
|
||||
|
||||
matrix?.audioPlayer?.dispose();
|
||||
matrix?.audioPlayer = AudioPlayer();
|
||||
matrix?.voiceMessageEventId.value =
|
||||
"${widget.overlayController.pangeaMessageEvent.eventId}_button";
|
||||
matrix?.voiceMessageEventId.value = "${messageEvent.eventId}_button";
|
||||
|
||||
_onPlayerStateChanged =
|
||||
matrix?.audioPlayer?.playerStateStream.listen((state) {
|
||||
if (state.processingState == ProcessingState.completed) {
|
||||
_updateMode(null);
|
||||
}
|
||||
setState(() {});
|
||||
});
|
||||
_playerStateSub?.cancel();
|
||||
_playerStateSub =
|
||||
matrix?.audioPlayer?.playerStateStream.listen(_onUpdatePlayerState);
|
||||
|
||||
_onAudioPositionChanged ??=
|
||||
matrix?.audioPlayer?.positionStream.listen((state) {
|
||||
if (_audioBytes?.tokens != null) {
|
||||
widget.overlayController.highlightCurrentText(
|
||||
state.inMilliseconds,
|
||||
_audioBytes!.tokens!,
|
||||
);
|
||||
}
|
||||
});
|
||||
_audioSub?.cancel();
|
||||
_audioSub = matrix?.audioPlayer?.positionStream.listen(_onPlayAudio);
|
||||
|
||||
try {
|
||||
if (matrix?.audioPlayer != null &&
|
||||
matrix!.audioPlayer!.playerState.playing) {
|
||||
await matrix?.audioPlayer?.pause();
|
||||
await matrix!.audioPlayer!.pause();
|
||||
return;
|
||||
} else if (matrix?.audioPlayer?.position != Duration.zero) {
|
||||
TtsController.stop();
|
||||
|
|
@ -350,19 +258,21 @@ class SelectModeButtonsState extends State<SelectModeButtons> {
|
|||
return;
|
||||
}
|
||||
|
||||
if (_audioBytes == null) {
|
||||
await _fetchAudio();
|
||||
if (controller.audioFile == null) {
|
||||
await controller.fetchAudio();
|
||||
}
|
||||
|
||||
if (_audioBytes == null) return;
|
||||
if (controller.audioFile == null) return;
|
||||
final (PangeaAudioFile pangeaAudioFile, File? audioFile) =
|
||||
controller.audioFile!;
|
||||
|
||||
if (_audioFile != null) {
|
||||
await matrix?.audioPlayer?.setFilePath(_audioFile!.path);
|
||||
if (audioFile != null) {
|
||||
await matrix?.audioPlayer?.setFilePath(audioFile.path);
|
||||
} else {
|
||||
await matrix?.audioPlayer?.setAudioSource(
|
||||
BytesAudioSource(
|
||||
_audioBytes!.bytes,
|
||||
_audioBytes!.mimeType,
|
||||
pangeaAudioFile.bytes,
|
||||
pangeaAudioFile.mimeType,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -370,193 +280,42 @@ class SelectModeButtonsState extends State<SelectModeButtons> {
|
|||
TtsController.stop();
|
||||
await matrix?.audioPlayer?.play();
|
||||
} catch (e, s) {
|
||||
setState(() => _audioError = e.toString());
|
||||
ErrorHandler.logError(
|
||||
e: e,
|
||||
s: s,
|
||||
m: 'something wrong playing message audio',
|
||||
data: {
|
||||
'event': messageEvent?.event.toJson(),
|
||||
'event': messageEvent.event.toJson(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _fetchTranslation() async {
|
||||
if (l1Code == null ||
|
||||
messageEvent == null ||
|
||||
widget.overlayController.translation != null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (mounted) setState(() => _isLoadingTranslation = true);
|
||||
final rep = await messageEvent!.l1Respresentation();
|
||||
widget.overlayController.setTranslation(rep.text);
|
||||
} catch (e, s) {
|
||||
_translationError = e.toString();
|
||||
ErrorHandler.logError(
|
||||
e: e,
|
||||
s: s,
|
||||
m: 'Error fetching translation',
|
||||
data: {
|
||||
'l1Code': l1Code,
|
||||
'messageEvent': messageEvent?.event.toJson(),
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
if (mounted) setState(() => _isLoadingTranslation = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _fetchTranscription() async {
|
||||
try {
|
||||
if (_transcriptionCompleter != null) {
|
||||
// If a transcription is already in progress, wait for it to complete
|
||||
await _transcriptionCompleter!.future;
|
||||
return;
|
||||
}
|
||||
|
||||
_transcriptionCompleter = Completer<String>();
|
||||
if (l1Code == null || messageEvent == null) {
|
||||
_transcriptionCompleter?.completeError(
|
||||
'Language code or message event is null',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final resp = await messageEvent!.getSpeechToText(
|
||||
l1Code!,
|
||||
l2Code!,
|
||||
);
|
||||
|
||||
widget.overlayController.setTranscription(resp!);
|
||||
_transcriptionCompleter?.complete(resp.transcript.text);
|
||||
} catch (err, s) {
|
||||
widget.overlayController.setTranscriptionError(
|
||||
err.toString(),
|
||||
);
|
||||
_transcriptionCompleter?.completeError(err);
|
||||
ErrorHandler.logError(
|
||||
e: err,
|
||||
s: s,
|
||||
data: {},
|
||||
void _onPlayAudio(Duration duration) {
|
||||
if (controller.audioFile?.$1.tokens != null) {
|
||||
widget.overlayController.highlightCurrentText(
|
||||
duration.inMilliseconds,
|
||||
controller.audioFile!.$1.tokens!,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _fetchSpeechTranslation() async {
|
||||
if (messageEvent == null ||
|
||||
l1Code == null ||
|
||||
l2Code == null ||
|
||||
widget.overlayController.speechTranslation != null) {
|
||||
return;
|
||||
void _onUpdatePlayerState(PlayerState state) {
|
||||
if (state.processingState == ProcessingState.completed) {
|
||||
updateMode(null);
|
||||
}
|
||||
|
||||
try {
|
||||
setState(() => _isLoadingSpeechTranslation = true);
|
||||
|
||||
if (widget.overlayController.transcription == null) {
|
||||
await _fetchTranscription();
|
||||
if (widget.overlayController.transcription == null) {
|
||||
throw Exception('Transcription is null');
|
||||
}
|
||||
}
|
||||
|
||||
final translation = await messageEvent!.sttTranslationByLanguageGlobal(
|
||||
langCode: l1Code!,
|
||||
l1Code: l1Code!,
|
||||
l2Code: l2Code!,
|
||||
);
|
||||
if (translation == null) {
|
||||
throw Exception('Translation is null');
|
||||
}
|
||||
|
||||
widget.overlayController.setSpeechTranslation(translation.translation);
|
||||
} catch (err, s) {
|
||||
debugPrint("Error fetching speech translation: $err, $s");
|
||||
_speechTranslationError = err.toString();
|
||||
ErrorHandler.logError(
|
||||
e: err,
|
||||
data: {},
|
||||
);
|
||||
} finally {
|
||||
if (mounted) setState(() => _isLoadingSpeechTranslation = false);
|
||||
}
|
||||
}
|
||||
|
||||
bool get _isError {
|
||||
switch (_selectedMode) {
|
||||
case SelectMode.audio:
|
||||
return _audioError != null;
|
||||
case SelectMode.translate:
|
||||
return _translationError != null;
|
||||
case SelectMode.speechTranslation:
|
||||
return _speechTranslationError != null;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
bool get _isLoading {
|
||||
switch (_selectedMode) {
|
||||
case SelectMode.audio:
|
||||
return _isLoadingAudio;
|
||||
case SelectMode.translate:
|
||||
return _isLoadingTranslation;
|
||||
case SelectMode.speechTranslation:
|
||||
return _isLoadingSpeechTranslation;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Widget _icon(SelectMode mode) {
|
||||
if (_isError && mode == _selectedMode) {
|
||||
return Icon(
|
||||
Icons.error_outline,
|
||||
size: 20,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
);
|
||||
}
|
||||
|
||||
if (_isLoading && mode == _selectedMode) {
|
||||
return const Center(
|
||||
child: SizedBox(
|
||||
height: 20.0,
|
||||
width: 20.0,
|
||||
child: CircularProgressIndicator.adaptive(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (mode == SelectMode.audio) {
|
||||
return Icon(
|
||||
matrix?.audioPlayer?.playerState.playing == true
|
||||
? Icons.pause_outlined
|
||||
: Icons.volume_up,
|
||||
size: 20,
|
||||
);
|
||||
}
|
||||
|
||||
return Icon(
|
||||
mode.icon,
|
||||
size: 20,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final isSubscribed =
|
||||
MatrixState.pangeaController.subscriptionController.isSubscribed;
|
||||
List<SelectMode> modes = widget.overlayController.showLanguageAssistance
|
||||
? messageEvent?.isAudioMessage == true
|
||||
? audioModes
|
||||
: textModes
|
||||
: [];
|
||||
final List<SelectMode> modes =
|
||||
widget.overlayController.showLanguageAssistance
|
||||
? messageEvent.isAudioMessage == true
|
||||
? audioModes
|
||||
: textModes
|
||||
: [];
|
||||
|
||||
if (isSubscribed == false) modes = [];
|
||||
return Material(
|
||||
type: MaterialType.transparency,
|
||||
child: SizedBox(
|
||||
|
|
@ -571,24 +330,42 @@ class SelectModeButtonsState extends State<SelectModeButtons> {
|
|||
alignment: Alignment.center,
|
||||
child: Tooltip(
|
||||
message: mode.tooltip(context),
|
||||
child: PressableButton(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
depressed: mode == _selectedMode,
|
||||
color: theme.colorScheme.primaryContainer,
|
||||
onPressed: () => _updateMode(mode),
|
||||
playSound: true,
|
||||
colorFactor:
|
||||
theme.brightness == Brightness.light ? 0.55 : 0.3,
|
||||
child: AnimatedContainer(
|
||||
duration: FluffyThemes.animationDuration,
|
||||
height: buttonSize,
|
||||
width: buttonSize,
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.primaryContainer,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: _icon(mode),
|
||||
child: ListenableBuilder(
|
||||
listenable: Listenable.merge(
|
||||
[
|
||||
controller.selectedMode,
|
||||
controller.modeStateNotifier(mode),
|
||||
],
|
||||
),
|
||||
builder: (context, _) {
|
||||
final selectedMode = controller.selectedMode.value;
|
||||
return PressableButton(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
depressed: mode == selectedMode,
|
||||
color: theme.colorScheme.primaryContainer,
|
||||
onPressed: () => updateMode(mode),
|
||||
playSound: mode != SelectMode.audio,
|
||||
colorFactor:
|
||||
theme.brightness == Brightness.light ? 0.55 : 0.3,
|
||||
child: AnimatedContainer(
|
||||
duration: FluffyThemes.animationDuration,
|
||||
height: buttonSize,
|
||||
width: buttonSize,
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.primaryContainer,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: _SelectModeButtonIcon(
|
||||
mode: mode,
|
||||
loading:
|
||||
controller.isLoading && mode == selectedMode,
|
||||
playing: mode == SelectMode.audio &&
|
||||
matrix?.audioPlayer?.playerState.playing ==
|
||||
true,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
@ -596,9 +373,9 @@ class SelectModeButtonsState extends State<SelectModeButtons> {
|
|||
return Container(
|
||||
width: 45.0,
|
||||
alignment: Alignment.center,
|
||||
child: MoreButton(
|
||||
child: _MoreButton(
|
||||
controller: widget.controller,
|
||||
messageEvent: widget.overlayController.pangeaMessageEvent,
|
||||
messageEvent: messageEvent,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -609,12 +386,45 @@ class SelectModeButtonsState extends State<SelectModeButtons> {
|
|||
}
|
||||
}
|
||||
|
||||
class MoreButton extends StatelessWidget {
|
||||
class _SelectModeButtonIcon extends StatelessWidget {
|
||||
final SelectMode mode;
|
||||
final bool loading;
|
||||
final bool playing;
|
||||
|
||||
const _SelectModeButtonIcon({
|
||||
required this.mode,
|
||||
this.loading = false,
|
||||
this.playing = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (loading) {
|
||||
return const Center(
|
||||
child: SizedBox(
|
||||
height: 20.0,
|
||||
width: 20.0,
|
||||
child: CircularProgressIndicator.adaptive(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (mode == SelectMode.audio) {
|
||||
return Icon(
|
||||
playing ? Icons.pause_outlined : Icons.volume_up,
|
||||
size: 20,
|
||||
);
|
||||
}
|
||||
|
||||
return Icon(mode.icon, size: 20);
|
||||
}
|
||||
}
|
||||
|
||||
class _MoreButton extends StatelessWidget {
|
||||
final ChatController controller;
|
||||
final PangeaMessageEvent? messageEvent;
|
||||
|
||||
const MoreButton({
|
||||
super.key,
|
||||
const _MoreButton({
|
||||
required this.controller,
|
||||
this.messageEvent,
|
||||
});
|
||||
|
|
|
|||
284
lib/pangea/toolbar/widgets/select_mode_controller.dart
Normal file
284
lib/pangea/toolbar/widgets/select_mode_controller.dart
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'package:matrix/matrix.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import 'package:fluffychat/pangea/common/utils/async_state.dart';
|
||||
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
|
||||
import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart';
|
||||
import 'package:fluffychat/pangea/events/extensions/pangea_event_extension.dart';
|
||||
import 'package:fluffychat/pangea/toolbar/models/speech_to_text_models.dart';
|
||||
import 'package:fluffychat/pangea/toolbar/widgets/message_audio_card.dart';
|
||||
import 'package:fluffychat/pangea/toolbar/widgets/select_mode_buttons.dart';
|
||||
import 'package:fluffychat/widgets/matrix.dart';
|
||||
|
||||
class SelectModeController {
|
||||
final PangeaMessageEvent messageEvent;
|
||||
|
||||
SelectModeController(
|
||||
this.messageEvent,
|
||||
);
|
||||
|
||||
ValueNotifier<SelectMode?> selectedMode = ValueNotifier<SelectMode?>(null);
|
||||
|
||||
final ValueNotifier<AsyncState<SpeechToTextModel>> transcriptionState =
|
||||
ValueNotifier<AsyncState<SpeechToTextModel>>(const AsyncState.idle());
|
||||
|
||||
final ValueNotifier<AsyncState<String>> translationState =
|
||||
ValueNotifier<AsyncState<String>>(const AsyncState.idle());
|
||||
|
||||
final ValueNotifier<AsyncState<String>> speechTranslationState =
|
||||
ValueNotifier<AsyncState<String>>(const AsyncState.idle());
|
||||
|
||||
final ValueNotifier<AsyncState<(PangeaAudioFile, File?)>> audioState =
|
||||
ValueNotifier<AsyncState<(PangeaAudioFile, File?)>>(
|
||||
const AsyncState.idle(),
|
||||
);
|
||||
|
||||
final StreamController contentChangedStream = StreamController.broadcast();
|
||||
|
||||
bool _disposed = false;
|
||||
|
||||
bool get showingExtraContent =>
|
||||
(selectedMode.value == SelectMode.translate &&
|
||||
translationState.value is AsyncLoaded) ||
|
||||
(selectedMode.value == SelectMode.speechTranslation &&
|
||||
speechTranslationState.value is AsyncLoaded) ||
|
||||
transcriptionState.value is AsyncLoaded ||
|
||||
transcriptionState.value is AsyncError;
|
||||
|
||||
String? get l1Code =>
|
||||
MatrixState.pangeaController.languageController.userL1?.langCodeShort;
|
||||
String? get l2Code =>
|
||||
MatrixState.pangeaController.languageController.userL2?.langCodeShort;
|
||||
|
||||
(PangeaAudioFile, File?)? get audioFile => audioState.value is AsyncLoaded
|
||||
? (audioState.value as AsyncLoaded<(PangeaAudioFile, File?)>).value
|
||||
: null;
|
||||
|
||||
ValueNotifier<AsyncState>? modeStateNotifier(SelectMode mode) {
|
||||
switch (mode) {
|
||||
case SelectMode.audio:
|
||||
return audioState;
|
||||
case SelectMode.translate:
|
||||
return translationState;
|
||||
case SelectMode.speechTranslation:
|
||||
return speechTranslationState;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
ValueNotifier<AsyncState>? get currentModeStateNotifier {
|
||||
final mode = selectedMode.value;
|
||||
if (mode == null) return null;
|
||||
return modeStateNotifier(mode);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
selectedMode.dispose();
|
||||
transcriptionState.dispose();
|
||||
translationState.dispose();
|
||||
speechTranslationState.dispose();
|
||||
audioState.dispose();
|
||||
contentChangedStream.close();
|
||||
_disposed = true;
|
||||
}
|
||||
|
||||
void setSelectMode(SelectMode? mode) {
|
||||
if (selectedMode.value == mode) return;
|
||||
selectedMode.value = mode;
|
||||
}
|
||||
|
||||
Future<void> fetchAudio() async {
|
||||
audioState.value = const AsyncState.loading();
|
||||
try {
|
||||
final String langCode = messageEvent.messageDisplayLangCode;
|
||||
final Event? localEvent = messageEvent.getTextToSpeechLocal(
|
||||
langCode,
|
||||
messageEvent.messageDisplayText,
|
||||
);
|
||||
|
||||
PangeaAudioFile? audioBytes;
|
||||
if (localEvent != null) {
|
||||
audioBytes = await localEvent.getPangeaAudioFile();
|
||||
} else {
|
||||
audioBytes = await messageEvent.getMatrixAudioFile(
|
||||
langCode,
|
||||
);
|
||||
}
|
||||
if (_disposed) return;
|
||||
if (audioBytes == null) {
|
||||
throw Exception('Audio bytes are null');
|
||||
}
|
||||
|
||||
File? audioFile;
|
||||
if (!kIsWeb) {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
|
||||
File? file;
|
||||
file = File('${tempDir.path}/${audioBytes.name}');
|
||||
await file.writeAsBytes(audioBytes.bytes);
|
||||
audioFile = file;
|
||||
}
|
||||
|
||||
audioState.value = AsyncState.loaded((audioBytes, audioFile));
|
||||
} catch (e, s) {
|
||||
ErrorHandler.logError(
|
||||
e: e,
|
||||
s: s,
|
||||
m: 'something wrong getting audio in MessageAudioCardState',
|
||||
data: {
|
||||
'widget.messageEvent.messageDisplayLangCode':
|
||||
messageEvent.messageDisplayLangCode,
|
||||
},
|
||||
);
|
||||
if (_disposed) return;
|
||||
audioState.value = AsyncState.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> fetchTranslation() async {
|
||||
if (l1Code == null ||
|
||||
translationState.value is AsyncLoading ||
|
||||
translationState.value is AsyncLoaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
translationState.value = const AsyncState.loading();
|
||||
final rep = await messageEvent.l1Respresentation();
|
||||
if (_disposed) return;
|
||||
translationState.value = AsyncState.loaded(rep.text);
|
||||
} catch (e, s) {
|
||||
ErrorHandler.logError(
|
||||
e: e,
|
||||
s: s,
|
||||
m: 'Error fetching translation',
|
||||
data: {
|
||||
'l1Code': l1Code,
|
||||
'messageEvent': messageEvent.event.toJson(),
|
||||
},
|
||||
);
|
||||
if (_disposed) return;
|
||||
translationState.value = AsyncState.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> fetchTranscription() async {
|
||||
try {
|
||||
if (transcriptionState.value is AsyncLoading ||
|
||||
transcriptionState.value is AsyncLoaded) {
|
||||
// If a transcription is already in progress or finished, don't fetch again
|
||||
return;
|
||||
}
|
||||
|
||||
if (l1Code == null || l2Code == null) {
|
||||
transcriptionState.value = const AsyncState.error(
|
||||
'Language code or message event is null',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final resp = await messageEvent.getSpeechToText(
|
||||
l1Code!,
|
||||
l2Code!,
|
||||
);
|
||||
|
||||
if (_disposed) return;
|
||||
if (resp == null) {
|
||||
transcriptionState.value = const AsyncState.error(
|
||||
'Transcription response is null',
|
||||
);
|
||||
return;
|
||||
}
|
||||
transcriptionState.value = AsyncState.loaded(resp);
|
||||
} catch (err, s) {
|
||||
ErrorHandler.logError(
|
||||
e: err,
|
||||
s: s,
|
||||
data: {},
|
||||
);
|
||||
if (_disposed) return;
|
||||
transcriptionState.value = AsyncState.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> fetchSpeechTranslation() async {
|
||||
if (l1Code == null ||
|
||||
l2Code == null ||
|
||||
speechTranslationState.value is AsyncLoading ||
|
||||
speechTranslationState.value is AsyncLoaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (transcriptionState.value is AsyncError) {
|
||||
speechTranslationState.value = AsyncState.error(
|
||||
(transcriptionState.value as AsyncError).error,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
speechTranslationState.value = const AsyncState.loading();
|
||||
|
||||
if (transcriptionState.value is AsyncIdle ||
|
||||
transcriptionState.value is AsyncLoading) {
|
||||
await fetchTranscription();
|
||||
if (_disposed) return;
|
||||
if (transcriptionState.value is! AsyncLoaded) {
|
||||
throw Exception('Transcription is null');
|
||||
}
|
||||
}
|
||||
|
||||
final translation = await messageEvent.sttTranslationByLanguageGlobal(
|
||||
langCode: l1Code!,
|
||||
l1Code: l1Code!,
|
||||
l2Code: l2Code!,
|
||||
);
|
||||
if (translation == null) {
|
||||
throw Exception('Translation is null');
|
||||
}
|
||||
|
||||
if (_disposed) return;
|
||||
speechTranslationState.value = AsyncState.loaded(translation.translation);
|
||||
} catch (err, s) {
|
||||
ErrorHandler.logError(
|
||||
e: err,
|
||||
s: s,
|
||||
data: {},
|
||||
);
|
||||
if (_disposed) return;
|
||||
speechTranslationState.value = AsyncState.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
bool get isError {
|
||||
switch (selectedMode.value) {
|
||||
case SelectMode.audio:
|
||||
return audioState.value is AsyncError;
|
||||
case SelectMode.translate:
|
||||
return translationState.value is AsyncError;
|
||||
case SelectMode.speechTranslation:
|
||||
return speechTranslationState.value is AsyncError;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
bool get isLoading {
|
||||
switch (selectedMode.value) {
|
||||
case SelectMode.audio:
|
||||
return audioState.value is AsyncLoading;
|
||||
case SelectMode.translate:
|
||||
return translationState.value is AsyncLoading;
|
||||
case SelectMode.speechTranslation:
|
||||
return speechTranslationState.value is AsyncLoading;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -11,12 +11,15 @@ class WordCardSwitcher extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedSize(
|
||||
alignment:
|
||||
controller.ownMessage ? Alignment.bottomRight : Alignment.bottomLeft,
|
||||
duration: FluffyThemes.animationDuration,
|
||||
child:
|
||||
controller.widget.overlayController.selectedMode == SelectMode.emoji
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: controller.widget.overlayController.selectedMode,
|
||||
builder: (context, mode, __) {
|
||||
return AnimatedSize(
|
||||
alignment: controller.ownMessage
|
||||
? Alignment.bottomRight
|
||||
: Alignment.bottomLeft,
|
||||
duration: FluffyThemes.animationDuration,
|
||||
child: mode == SelectMode.emoji
|
||||
? const SizedBox()
|
||||
: controller.widget.overlayController.selectedToken != null
|
||||
? ReadingAssistanceContent(
|
||||
|
|
@ -25,6 +28,8 @@ class WordCardSwitcher extends StatelessWidget {
|
|||
: MessageReactionPicker(
|
||||
chatController: controller.widget.chatController,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -150,6 +150,7 @@ abstract class ClientManager {
|
|||
PangeaEventTypes.activityRoomIds,
|
||||
PangeaEventTypes.coursePlan,
|
||||
PangeaEventTypes.courseUser,
|
||||
PangeaEventTypes.teacherMode,
|
||||
// Pangea#
|
||||
},
|
||||
logLevel: kReleaseMode ? Level.warning : Level.verbose,
|
||||
|
|
|
|||
|
|
@ -241,7 +241,10 @@ class MatrixState extends State<Matrix> with WidgetsBindingObserver {
|
|||
String? get activeRoomId {
|
||||
final route = FluffyChatApp.router.routeInformationProvider.value.uri.path;
|
||||
if (!route.startsWith('/rooms/')) return null;
|
||||
return route.split('/')[2];
|
||||
// #Pangea
|
||||
// return route.split('/')[2];
|
||||
return FluffyChatApp.router.state.pathParameters['roomid'];
|
||||
// Pangea#
|
||||
}
|
||||
|
||||
final linuxNotifications =
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ description: Learn a language while texting your friends.
|
|||
# Pangea#
|
||||
publish_to: none
|
||||
# On version bump also increase the build number for F-Droid
|
||||
version: 4.1.15+5
|
||||
version: 4.1.15+7
|
||||
|
||||
environment:
|
||||
sdk: ">=3.0.0 <4.0.0"
|
||||
|
|
|
|||
118
scripts/FIND_UNUSED_INTL_KEYS.md
Normal file
118
scripts/FIND_UNUSED_INTL_KEYS.md
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
# Find Unused Translation Keys Script
|
||||
|
||||
This Python script helps identify unused translation keys in the `intl_en.arb` file that are not referenced anywhere in the codebase.
|
||||
|
||||
## Purpose
|
||||
|
||||
The script was created to clean up the internationalization (i18n) files by finding translation keys that are defined but never used. This helps maintain a cleaner codebase and reduces translation overhead.
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **Extracts Keys**: Reads `lib/l10n/intl_en.arb` and extracts all translation keys after line 3243 (configurable)
|
||||
2. **Filters Metadata**: Automatically excludes keys starting with `@` (metadata keys)
|
||||
3. **Filters Placeholders**: Excludes nested placeholder keys inside metadata objects (e.g., `l1`, `l2`, `type`, `placeholders`)
|
||||
4. **Searches Repository**: Uses `git grep` to efficiently search for each key in the repository
|
||||
5. **Filters Results**: Excludes matches found only in `.arb` files (other language files)
|
||||
6. **Reports Findings**: Generates a JSON file with the list of unused keys
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# Run from repository root
|
||||
python3 scripts/find_unused_intl_keys.py
|
||||
```
|
||||
|
||||
## Output
|
||||
|
||||
The script provides two types of output:
|
||||
|
||||
1. **Console Output**: Real-time progress and summary of findings
|
||||
2. **JSON File**: List of unused keys saved to `scripts/unused_intl_keys.json`
|
||||
|
||||
### Sample Console Output
|
||||
|
||||
```
|
||||
Extracting keys from /path/to/intl_en.arb after line 3243...
|
||||
Found 1869 translation keys to check.
|
||||
|
||||
Searching repository for key references...
|
||||
Checked 10/1869 keys...
|
||||
...
|
||||
|
||||
Search complete!
|
||||
Total keys checked: 1869
|
||||
Used keys: 1381
|
||||
Unused keys: 488
|
||||
|
||||
================================================================================
|
||||
RESULTS
|
||||
================================================================================
|
||||
|
||||
Found 488 unused keys (not referenced in any .dart files):
|
||||
--------------------------------------------------------------------------------
|
||||
- aaDisplayName
|
||||
- abDisplayName
|
||||
- acceptSelection
|
||||
...
|
||||
```
|
||||
|
||||
### Sample JSON Output
|
||||
|
||||
```json
|
||||
{
|
||||
"unused_keys": [
|
||||
"aaDisplayName",
|
||||
"abDisplayName",
|
||||
"acceptSelection",
|
||||
...
|
||||
],
|
||||
"count": 488,
|
||||
"source_file": "/path/to/lib/l10n/intl_en.arb",
|
||||
"start_line": 3243
|
||||
}
|
||||
```
|
||||
|
||||
## Understanding the Results
|
||||
|
||||
- **Unused keys**: Translation keys that appear only in `.arb` files and nowhere else in the codebase
|
||||
- **Metadata keys** (starting with `@`) are automatically excluded from the analysis
|
||||
- **Placeholder keys** (nested inside metadata objects like `placeholders`) are automatically excluded
|
||||
|
||||
## Notes
|
||||
|
||||
- Keys starting with `@` are metadata and are automatically skipped
|
||||
- Nested keys inside metadata objects (like `l1`, `l2` in placeholders) are automatically filtered out
|
||||
- Only top-level translation keys are analyzed
|
||||
- The script searches only for exact key matches in the repository
|
||||
- False positives are possible if keys are constructed dynamically (e.g., using string interpolation)
|
||||
- Always review the unused keys list before removing them from the translation files
|
||||
|
||||
## Customization
|
||||
|
||||
To check from a different line number, modify the `start_line` parameter in the `main()` function:
|
||||
|
||||
```python
|
||||
unused_keys = find_unused_keys(str(arb_file_path), str(repo_path), start_line=3243)
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.x
|
||||
- Git (for `git grep` command)
|
||||
- Repository must be a git repository
|
||||
|
||||
## Next Steps
|
||||
|
||||
After identifying unused keys:
|
||||
|
||||
1. Review the unused keys list in `scripts/unused_intl_keys.json`
|
||||
2. Determine which keys can be safely removed
|
||||
3. Remove unused keys from `intl_en.arb`
|
||||
4. Run the script again to verify
|
||||
5. Consider removing the same keys from other language `.arb` files
|
||||
|
||||
## Related Files
|
||||
|
||||
- Source translation file: `lib/l10n/intl_en.arb`
|
||||
- Other language files: `lib/l10n/intl_*.arb`
|
||||
- Generated JSON output: `scripts/unused_intl_keys.json`
|
||||
46
scripts/REMOVE_INTL_KEY.md
Normal file
46
scripts/REMOVE_INTL_KEY.md
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
# Remove Translation Key Script
|
||||
|
||||
This script removes a specific translation key from all `.arb` files in the project.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
python3 scripts/remove_intl_key.py <key_name>
|
||||
```
|
||||
|
||||
## Example
|
||||
|
||||
```bash
|
||||
python3 scripts/remove_intl_key.py "obsoleteKey"
|
||||
```
|
||||
|
||||
## What it does
|
||||
|
||||
1. **Takes a key name** as a command-line argument
|
||||
2. **Validates the key name** and cleans it (removes @ prefix if accidentally provided)
|
||||
3. **Searches all .arb files** in `lib/l10n/` directory
|
||||
4. **Removes both the key and its metadata**:
|
||||
- The main key-value pair (e.g., `"myKey": "My Value"`)
|
||||
- The associated metadata entry (e.g., `"@myKey": {...}`)
|
||||
5. **Preserves file structure** and formatting
|
||||
6. **Provides detailed feedback** about what was removed
|
||||
|
||||
## Safety Features
|
||||
|
||||
- **Confirmation prompt** before making changes
|
||||
- **Validates input** to prevent common mistakes
|
||||
- **Detailed reporting** of what was removed from each file
|
||||
- **Preserves JSON structure** and formatting
|
||||
- **Only modifies files** that actually contain the key
|
||||
|
||||
## Output
|
||||
|
||||
The script provides:
|
||||
- A list of processed files and what was removed from each
|
||||
- Summary of total entries removed
|
||||
- Warning if the key wasn't found in any files
|
||||
|
||||
## Related Scripts
|
||||
|
||||
- `find_unused_intl_keys.py` - Finds keys that are not used in the codebase
|
||||
- `remove_unused_intl_keys.py` - Removes all unused keys at once
|
||||
93
scripts/REMOVE_UNUSED_INTL_KEYS.md
Normal file
93
scripts/REMOVE_UNUSED_INTL_KEYS.md
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
# Remove Unused Translation Keys Script
|
||||
|
||||
This Python script removes unused translation keys from all `.arb` files in the repository.
|
||||
|
||||
## Purpose
|
||||
|
||||
After identifying unused keys with `find_unused_intl_keys.py`, this script automates the removal of those keys from all language files. It removes both the key-value pairs and their corresponding metadata entries.
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **Loads Unused Keys**: Reads the list of unused keys from `unused_intl_keys.json`
|
||||
2. **Processes Each File**: For each `.arb` file in `lib/l10n/`:
|
||||
- Loads the file as JSON
|
||||
- Identifies keys to remove (both base keys and their `@key` metadata)
|
||||
- Removes those keys while preserving order
|
||||
- Writes the cleaned JSON back to the file
|
||||
3. **Reports Results**: Shows how many keys were removed from each file
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# First, generate the list of unused keys
|
||||
python3 scripts/find_unused_intl_keys.py
|
||||
|
||||
# Then, remove those keys from all .arb files
|
||||
python3 scripts/remove_unused_intl_keys.py
|
||||
```
|
||||
|
||||
## Output
|
||||
|
||||
The script provides progress output showing how many keys were removed from each file:
|
||||
|
||||
```
|
||||
Loading unused keys from JSON file...
|
||||
Found 488 unused keys to remove.
|
||||
|
||||
Found 54 .arb files to process.
|
||||
|
||||
Processing .arb files...
|
||||
================================================================================
|
||||
intl_en.arb: Removed 488 keys/metadata entries
|
||||
intl_es.arb: Removed 557 keys/metadata entries
|
||||
intl_fr.arb: Removed 950 keys/metadata entries
|
||||
...
|
||||
================================================================================
|
||||
|
||||
Total keys/metadata entries removed: 50015
|
||||
Processed 54 .arb files successfully.
|
||||
```
|
||||
|
||||
## What Gets Removed
|
||||
|
||||
For each unused key (e.g., `accountInformation`), the script removes:
|
||||
1. The key itself: `"accountInformation": "Account information"`
|
||||
2. Its metadata (if present): `"@accountInformation": { ... }`
|
||||
|
||||
## Important Notes
|
||||
|
||||
- **Backup recommended**: The script modifies files in place. Consider committing your work or creating a backup before running.
|
||||
- **JSON parsing**: The script uses Python's JSON library, which:
|
||||
- Preserves the order of keys (using OrderedDict)
|
||||
- May reformat indentation to 2 spaces
|
||||
- Resolves duplicate keys by keeping the last value
|
||||
- **Validation**: After running, verify the files are still valid JSON and that the application still works correctly.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.x
|
||||
- Input file: `scripts/unused_intl_keys.json` (generated by `find_unused_intl_keys.py`)
|
||||
|
||||
## Example
|
||||
|
||||
```bash
|
||||
# Full workflow
|
||||
cd /path/to/repository
|
||||
|
||||
# Step 1: Find unused keys
|
||||
python3 scripts/find_unused_intl_keys.py
|
||||
|
||||
# Step 2: Review the list in scripts/unused_intl_keys.json
|
||||
|
||||
# Step 3: Remove the unused keys
|
||||
python3 scripts/remove_unused_intl_keys.py
|
||||
|
||||
# Step 4: Verify the changes
|
||||
git diff lib/l10n/
|
||||
```
|
||||
|
||||
## Related Files
|
||||
|
||||
- Input: `scripts/unused_intl_keys.json`
|
||||
- Modified: All `lib/l10n/intl_*.arb` files
|
||||
- Related: `scripts/find_unused_intl_keys.py` (generates the input file)
|
||||
205
scripts/find_unused_intl_keys.py
Executable file
205
scripts/find_unused_intl_keys.py
Executable file
|
|
@ -0,0 +1,205 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Script to find unused translation keys in intl_en.arb after line 3243.
|
||||
|
||||
This script:
|
||||
1. Reads intl_en.arb and extracts all translation keys after line 3243
|
||||
2. Filters out metadata keys (those starting with @)
|
||||
3. Searches the repository for references to each key
|
||||
4. Returns a JSON file with unused keys
|
||||
|
||||
Usage:
|
||||
python3 scripts/find_unused_intl_keys.py
|
||||
|
||||
Output:
|
||||
scripts/unused_intl_keys.json - JSON file containing the list of unused keys
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Set, List
|
||||
|
||||
|
||||
def extract_keys_after_line(arb_file_path: str, start_line: int = 3243) -> List[str]:
|
||||
"""
|
||||
Extract translation keys from .arb file after a specific line.
|
||||
|
||||
ARB files are JSON files where keys starting with @ are metadata.
|
||||
We only want the actual translation keys (non-@ keys), not placeholder
|
||||
keys or other nested metadata fields.
|
||||
|
||||
This function extracts only TOP-LEVEL keys that first appear after the
|
||||
specified line number. Keys that appear as placeholders are ignored.
|
||||
|
||||
Args:
|
||||
arb_file_path: Path to the .arb file
|
||||
start_line: Line number to start extracting from (1-indexed)
|
||||
|
||||
Returns:
|
||||
List of translation key names
|
||||
"""
|
||||
# Load the entire JSON to get proper structure
|
||||
with open(arb_file_path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Read file again to get line numbers for each key
|
||||
with open(arb_file_path, 'r', encoding='utf-8') as f:
|
||||
lines = f.readlines()
|
||||
|
||||
keys = []
|
||||
|
||||
# Extract only top-level keys (not nested keys inside metadata)
|
||||
for key in data.keys():
|
||||
# Skip metadata keys (those starting with @)
|
||||
if key.startswith('@'):
|
||||
continue
|
||||
|
||||
# Find the FIRST occurrence of this key as a top-level definition
|
||||
# A top-level key appears at the start of a line (after whitespace)
|
||||
# with the pattern: "keyName": (not nested inside another object)
|
||||
for line_num, line in enumerate(lines, start=1):
|
||||
# Match key at the beginning of a line (indentation level 1)
|
||||
# This ensures we're matching top-level keys, not nested ones
|
||||
if re.match(r'^ "' + re.escape(key) + r'":\s*', line):
|
||||
# Only include keys that appear after the specified line
|
||||
if line_num > start_line:
|
||||
keys.append(key)
|
||||
break
|
||||
|
||||
return keys
|
||||
|
||||
|
||||
def search_key_in_repository(key: str, repo_path: str, exclude_dirs: Set[str]) -> bool:
|
||||
"""
|
||||
Search for a key in the repository using git grep for efficiency.
|
||||
|
||||
Args:
|
||||
key: Translation key to search for
|
||||
repo_path: Path to the repository root
|
||||
exclude_dirs: Set of directory names to exclude from search
|
||||
|
||||
Returns:
|
||||
True if the key is found, False otherwise
|
||||
"""
|
||||
try:
|
||||
# Use git grep for fast searching, excluding the l10n directory
|
||||
# We search for the key name as it would appear in Dart code
|
||||
result = subprocess.run(
|
||||
['git', 'grep', '-q', key],
|
||||
cwd=repo_path,
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
|
||||
# git grep returns 0 if found, 1 if not found
|
||||
if result.returncode == 0:
|
||||
# Found the key, but we need to verify it's not just in the .arb files
|
||||
# Run again with output to check the files
|
||||
result_with_output = subprocess.run(
|
||||
['git', 'grep', '-l', key],
|
||||
cwd=repo_path,
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
|
||||
# Check if any non-.arb files contain the key
|
||||
files = result_with_output.stdout.strip().split('\n')
|
||||
for file in files:
|
||||
if not file.endswith('.arb'):
|
||||
return True
|
||||
|
||||
return False
|
||||
else:
|
||||
return False
|
||||
|
||||
except subprocess.CalledProcessError:
|
||||
return False
|
||||
|
||||
|
||||
def find_unused_keys(arb_file_path: str, repo_path: str, start_line: int = 3243) -> List[str]:
|
||||
"""
|
||||
Find unused translation keys in the repository.
|
||||
|
||||
Args:
|
||||
arb_file_path: Path to the .arb file
|
||||
repo_path: Path to the repository root
|
||||
start_line: Line number to start checking from
|
||||
|
||||
Returns:
|
||||
List of unused keys
|
||||
"""
|
||||
# Directories to exclude from search
|
||||
exclude_dirs = {'.git', 'build', 'node_modules', '.dart_tool', 'l10n'}
|
||||
|
||||
print(f"Extracting keys from {arb_file_path} after line {start_line}...")
|
||||
keys = extract_keys_after_line(arb_file_path, start_line)
|
||||
print(f"Found {len(keys)} translation keys to check.\n")
|
||||
|
||||
unused_keys = []
|
||||
used_count = 0
|
||||
|
||||
print("Searching repository for key references...")
|
||||
for i, key in enumerate(keys, 1):
|
||||
# Print progress every 10 keys
|
||||
if i % 10 == 0:
|
||||
print(f" Checked {i}/{len(keys)} keys...")
|
||||
|
||||
if search_key_in_repository(key, repo_path, exclude_dirs):
|
||||
used_count += 1
|
||||
else:
|
||||
unused_keys.append(key)
|
||||
|
||||
print(f"\nSearch complete!")
|
||||
print(f"Total keys checked: {len(keys)}")
|
||||
print(f"Used keys: {used_count}")
|
||||
print(f"Unused keys: {len(unused_keys)}")
|
||||
|
||||
return unused_keys
|
||||
|
||||
|
||||
def main():
|
||||
"""Main function to run the unused key finder."""
|
||||
# Get repository root
|
||||
repo_path = Path(__file__).parent.parent.absolute()
|
||||
arb_file_path = repo_path / 'lib' / 'l10n' / 'intl_en.arb'
|
||||
|
||||
if not arb_file_path.exists():
|
||||
print(f"Error: Could not find {arb_file_path}")
|
||||
return 1
|
||||
|
||||
# Find unused keys starting from line 3243
|
||||
unused_keys = find_unused_keys(str(arb_file_path), str(repo_path), start_line=3243)
|
||||
|
||||
# Print results
|
||||
print("\n" + "="*80)
|
||||
print("RESULTS")
|
||||
print("="*80)
|
||||
|
||||
if unused_keys:
|
||||
print(f"\nFound {len(unused_keys)} unused keys (not referenced in any .dart files):")
|
||||
print("-" * 80)
|
||||
for key in sorted(unused_keys):
|
||||
print(f" - {key}")
|
||||
else:
|
||||
print("\nNo unused keys found! All keys are referenced in the codebase.")
|
||||
|
||||
# Save results to JSON file
|
||||
output_file = repo_path / 'scripts' / 'unused_intl_keys.json'
|
||||
with open(output_file, 'w', encoding='utf-8') as f:
|
||||
json.dump({
|
||||
'unused_keys': sorted(unused_keys),
|
||||
'count': len(unused_keys),
|
||||
'source_file': str(arb_file_path),
|
||||
'start_line': 3243
|
||||
}, f, indent=2, ensure_ascii=False)
|
||||
|
||||
print(f"\nJSON output saved to: {output_file}")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
exit(main())
|
||||
177
scripts/remove_intl_key.py
Executable file
177
scripts/remove_intl_key.py
Executable file
|
|
@ -0,0 +1,177 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Script to remove a specific translation key from all .arb files.
|
||||
|
||||
This script:
|
||||
1. Takes a key name as a command-line argument
|
||||
2. Removes that key and its metadata entry from all .arb files
|
||||
3. Preserves the overall order and structure of the files
|
||||
|
||||
Usage:
|
||||
python3 scripts/remove_intl_key.py <key_name>
|
||||
|
||||
Example:
|
||||
python3 scripts/remove_intl_key.py "obsoleteKey"
|
||||
|
||||
Input:
|
||||
key_name - The name of the key to remove (without the @ prefix for metadata)
|
||||
|
||||
Output:
|
||||
Updates all .arb files in lib/l10n/ by removing the specified key and its metadata
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from collections import OrderedDict
|
||||
|
||||
|
||||
def remove_key_from_arb_file(arb_file_path: str, key_to_remove: str) -> int:
|
||||
"""
|
||||
Remove a specific key and its metadata from an .arb file.
|
||||
|
||||
This function removes both the key-value pair and its corresponding
|
||||
metadata entry (which is prefixed with @).
|
||||
|
||||
Args:
|
||||
arb_file_path: Path to the .arb file
|
||||
key_to_remove: Name of the key to remove
|
||||
|
||||
Returns:
|
||||
Number of entries removed from this file (0-2: key and/or metadata)
|
||||
"""
|
||||
# Read the JSON file
|
||||
with open(arb_file_path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f, object_pairs_hook=OrderedDict)
|
||||
|
||||
# Track what we remove
|
||||
removed_count = 0
|
||||
keys_to_delete = []
|
||||
|
||||
# Check if the main key exists
|
||||
if key_to_remove in data:
|
||||
keys_to_delete.append(key_to_remove)
|
||||
removed_count += 1
|
||||
|
||||
# Check if the metadata key exists
|
||||
metadata_key = f"@{key_to_remove}"
|
||||
if metadata_key in data:
|
||||
keys_to_delete.append(metadata_key)
|
||||
removed_count += 1
|
||||
|
||||
# Remove the keys
|
||||
for key in keys_to_delete:
|
||||
del data[key]
|
||||
|
||||
# Only write back if we actually removed something
|
||||
if removed_count > 0:
|
||||
with open(arb_file_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
f.write('\n') # Add trailing newline
|
||||
|
||||
return removed_count
|
||||
|
||||
|
||||
def validate_key_name(key_name: str) -> str:
|
||||
"""
|
||||
Validate and clean the key name.
|
||||
|
||||
Args:
|
||||
key_name: The key name provided by the user
|
||||
|
||||
Returns:
|
||||
Cleaned key name (without @ prefix if it was provided)
|
||||
|
||||
Raises:
|
||||
ValueError: If the key name is invalid
|
||||
"""
|
||||
if not key_name:
|
||||
raise ValueError("Key name cannot be empty")
|
||||
|
||||
# Remove @ prefix if user accidentally included it
|
||||
if key_name.startswith('@'):
|
||||
key_name = key_name[1:]
|
||||
|
||||
# Check if key name is still valid after cleaning
|
||||
if not key_name:
|
||||
raise ValueError("Key name cannot be just '@'")
|
||||
|
||||
# Validate key name format (basic validation)
|
||||
if not key_name.replace('_', '').replace('-', '').isalnum():
|
||||
print(f"Warning: Key name '{key_name}' contains special characters. This might not match any existing keys.")
|
||||
|
||||
return key_name
|
||||
|
||||
|
||||
def main():
|
||||
"""Main function to remove a specific key from all .arb files."""
|
||||
# Check command line arguments
|
||||
if len(sys.argv) != 2:
|
||||
print("Usage: python3 scripts/remove_intl_key.py <key_name>")
|
||||
print("Example: python3 scripts/remove_intl_key.py \"obsoleteKey\"")
|
||||
return 1
|
||||
|
||||
# Get and validate the key name
|
||||
try:
|
||||
key_to_remove = validate_key_name(sys.argv[1])
|
||||
except ValueError as e:
|
||||
print(f"Error: {e}")
|
||||
return 1
|
||||
|
||||
# Get repository root
|
||||
repo_path = Path(__file__).parent.parent.absolute()
|
||||
l10n_dir = repo_path / 'lib' / 'l10n'
|
||||
|
||||
if not l10n_dir.exists():
|
||||
print(f"Error: Could not find l10n directory at {l10n_dir}")
|
||||
return 1
|
||||
|
||||
# Get all .arb files
|
||||
arb_files = sorted(l10n_dir.glob('*.arb'))
|
||||
print(f"Found {len(arb_files)} .arb files to process.")
|
||||
|
||||
if not arb_files:
|
||||
print(f"Error: No .arb files found in {l10n_dir}")
|
||||
return 1
|
||||
|
||||
# Ask for confirmation
|
||||
print(f"\nAbout to remove key '{key_to_remove}' and its metadata '@{key_to_remove}' from all .arb files.")
|
||||
confirm = input("Do you want to continue? (y/N): ").lower().strip()
|
||||
|
||||
if confirm not in ['y', 'yes']:
|
||||
print("Operation cancelled.")
|
||||
return 0
|
||||
|
||||
# Process each .arb file
|
||||
total_removed = 0
|
||||
files_modified = 0
|
||||
print("\nProcessing .arb files...")
|
||||
print("=" * 80)
|
||||
|
||||
for arb_file in arb_files:
|
||||
removed = remove_key_from_arb_file(str(arb_file), key_to_remove)
|
||||
total_removed += removed
|
||||
if removed > 0:
|
||||
files_modified += 1
|
||||
entries = "entry" if removed == 1 else "entries"
|
||||
print(f"{arb_file.name}: Removed {removed} {entries}")
|
||||
else:
|
||||
print(f"{arb_file.name}: Key not found")
|
||||
|
||||
print("=" * 80)
|
||||
print(f"\nSummary:")
|
||||
print(f"Total entries removed: {total_removed}")
|
||||
print(f"Files modified: {files_modified}")
|
||||
print(f"Files processed: {len(arb_files)}")
|
||||
|
||||
if total_removed == 0:
|
||||
print(f"\nWarning: No occurrences of key '{key_to_remove}' were found in any .arb files.")
|
||||
print("Please check that the key name is correct and exists in the files.")
|
||||
else:
|
||||
print(f"\nSuccessfully removed key '{key_to_remove}' from all .arb files.")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
exit(main())
|
||||
135
scripts/remove_unused_intl_keys.py
Executable file
135
scripts/remove_unused_intl_keys.py
Executable file
|
|
@ -0,0 +1,135 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Script to remove unused translation keys from all .arb files.
|
||||
|
||||
This script:
|
||||
1. Reads the unused keys from unused_intl_keys.json
|
||||
2. Removes those keys and their metadata entries from all .arb files
|
||||
3. Preserves the overall order and structure of the files
|
||||
|
||||
Usage:
|
||||
python3 scripts/remove_unused_intl_keys.py
|
||||
|
||||
Input:
|
||||
scripts/unused_intl_keys.json - JSON file containing the list of unused keys
|
||||
|
||||
Output:
|
||||
Updates all .arb files in lib/l10n/ by removing unused keys and their metadata
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import List, Set
|
||||
from collections import OrderedDict
|
||||
|
||||
|
||||
def load_unused_keys(json_path: str) -> List[str]:
|
||||
"""
|
||||
Load the list of unused keys from the JSON file.
|
||||
|
||||
Args:
|
||||
json_path: Path to the unused_intl_keys.json file
|
||||
|
||||
Returns:
|
||||
List of unused key names
|
||||
"""
|
||||
with open(json_path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
return data['unused_keys']
|
||||
|
||||
|
||||
def remove_keys_from_arb_file(arb_file_path: str, keys_to_remove: Set[str]) -> int:
|
||||
"""
|
||||
Remove specified keys and their metadata from an .arb file.
|
||||
|
||||
This function removes both the key-value pairs and their corresponding
|
||||
metadata entries (which are prefixed with @).
|
||||
|
||||
Args:
|
||||
arb_file_path: Path to the .arb file
|
||||
keys_to_remove: Set of key names to remove
|
||||
|
||||
Returns:
|
||||
Number of keys removed from this file
|
||||
"""
|
||||
# Read the JSON file
|
||||
with open(arb_file_path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f, object_pairs_hook=OrderedDict)
|
||||
|
||||
# Track what we remove
|
||||
removed_count = 0
|
||||
keys_to_delete = []
|
||||
|
||||
# Identify all keys to remove (including metadata keys)
|
||||
for key in data.keys():
|
||||
# Check if this is a metadata key (starts with @)
|
||||
if key.startswith('@'):
|
||||
# Get the base key name
|
||||
base_key = key[1:]
|
||||
# Remove if the base key is in our removal list
|
||||
if base_key in keys_to_remove:
|
||||
keys_to_delete.append(key)
|
||||
removed_count += 1
|
||||
else:
|
||||
# Remove if the key itself is in our removal list
|
||||
if key in keys_to_remove:
|
||||
keys_to_delete.append(key)
|
||||
removed_count += 1
|
||||
|
||||
# Remove the keys
|
||||
for key in keys_to_delete:
|
||||
del data[key]
|
||||
|
||||
# Write back to file with proper formatting
|
||||
with open(arb_file_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
f.write('\n') # Add trailing newline
|
||||
|
||||
return removed_count
|
||||
|
||||
|
||||
def main():
|
||||
"""Main function to remove unused keys from all .arb files."""
|
||||
# Get repository root
|
||||
repo_path = Path(__file__).parent.parent.absolute()
|
||||
json_path = repo_path / 'scripts' / 'unused_intl_keys.json'
|
||||
l10n_dir = repo_path / 'lib' / 'l10n'
|
||||
|
||||
if not json_path.exists():
|
||||
print(f"Error: Could not find {json_path}")
|
||||
print("Please run find_unused_intl_keys.py first to generate the list of unused keys.")
|
||||
return 1
|
||||
|
||||
# Load unused keys
|
||||
print("Loading unused keys from JSON file...")
|
||||
unused_keys = load_unused_keys(str(json_path))
|
||||
keys_to_remove = set(unused_keys)
|
||||
print(f"Found {len(keys_to_remove)} unused keys to remove.\n")
|
||||
|
||||
# Get all .arb files
|
||||
arb_files = sorted(l10n_dir.glob('*.arb'))
|
||||
print(f"Found {len(arb_files)} .arb files to process.\n")
|
||||
|
||||
if not arb_files:
|
||||
print(f"Error: No .arb files found in {l10n_dir}")
|
||||
return 1
|
||||
|
||||
# Process each .arb file
|
||||
total_removed = 0
|
||||
print("Processing .arb files...")
|
||||
print("=" * 80)
|
||||
|
||||
for arb_file in arb_files:
|
||||
removed = remove_keys_from_arb_file(str(arb_file), keys_to_remove)
|
||||
total_removed += removed
|
||||
print(f"{arb_file.name}: Removed {removed} keys/metadata entries")
|
||||
|
||||
print("=" * 80)
|
||||
print(f"\nTotal keys/metadata entries removed: {total_removed}")
|
||||
print(f"Processed {len(arb_files)} .arb files successfully.")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
exit(main())
|
||||
Loading…
Add table
Reference in a new issue