merge main

This commit is contained in:
ggurdin 2025-07-01 10:59:35 -04:00
commit 7fa1064c5d
No known key found for this signature in database
GPG key ID: A01CB41737CBB478
64 changed files with 1836 additions and 2019 deletions

View file

@ -50,6 +50,11 @@ jobs:
cp public/.env public/assets/.env
touch public/assets/envs.json
echo "$ENV_OVERRIDES" >> public/assets/envs.json
mkdir -p public/.well-known
curl https://app.pangea.chat/.well-known/apple-app-site-association \
-o public/.well-known/apple-app-site-association
curl https://app.pangea.chat/.well-known/assetlinks.json \
-o public/.well-known/assetlinks.json
- name: Deploy to GitHub Pages
uses: peaceiris/actions-gh-pages@v4
with:

View file

@ -108,6 +108,19 @@
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="*/*" />
</intent-filter>
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="pangea" />
<data android:host="app.pangea.chat" />
</intent-filter>
<meta-data
android:name="flutter_deeplinking_enabled"
android:value="true" />
</activity>
<activity

View file

@ -28,6 +28,14 @@
<string>????</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>pangea</string>
</array>
<key>CFBundleURLName</key>
<string>com.talktolearn.chat</string>
</dict>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
@ -113,5 +121,7 @@
<false/>
<key>io.flutter.embedded_views_preview</key>
<true/>
<key>FlutterDeepLinkingEnabled</key>
<true/>
</dict>
</plist>

View file

@ -1,18 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>aps-environment</key>
<string>development</string>
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:example.com</string>
</array>
<key>com.apple.security.application-groups</key>
<!-- #Pangea -->
<array>
<string>group.com.talktolearn.chat</string>
</array>
<!-- Pangea# -->
</dict>
</plist>
<dict>
<key>aps-environment</key>
<string>development</string>
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:app.pangea.chat</string>
</array>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.talktolearn.chat</string>
</array>
</dict>
</plist>

View file

@ -44,7 +44,6 @@ abstract class AppConfig {
toolbarButtonsHeight +
(chatInputRowOverlayPadding * 2) +
toolbarSpacing;
static const double audioTranscriptionMaxHeight = 150.0;
static TextStyle messageTextStyle(
Event? event,

View file

@ -4630,9 +4630,9 @@
"meaningSectionHeader": "Meaning:",
"formSectionHeader": "Forms used in chats:",
"noEmojiSelectedTooltip": "No emoji selected",
"writingExercisesTooltip": "Writing practice",
"listeningExercisesTooltip": "Listening practice",
"readingExercisesTooltip": "Reading practice",
"writingExercisesTooltip": "Writing",
"listeningExercisesTooltip": "Listening",
"readingExercisesTooltip": "Reading",
"meaningNotFound": "Meaning could not be found.",
"formsNotFound": "Forms could not be found.",
"chooseBaseForm": "Choose the base form",
@ -5001,6 +5001,7 @@
"canBeFoundViaKnock": "\u2022 request to join and admin approval",
"anyoneCanJoin": "Anyone can join! However, admin can kick and ban whoever misbehaves. Those who are banned may not return!",
"createYourSpace": "Create your space",
"youHaveLeveledUp": "You have leveled up!",
"sendActivities": "Send activities",
"getStarted": "Get Started",
"getStartedBotChatDesc": "Chatting with AI is a great place to start and Pangea reading, writing, listening and speaking tools make it easy!",
@ -5015,11 +5016,8 @@
"groupChat": "Group Chat",
"directMessage": "Direct Message",
"newDirectMessage": "New direct message",
"speakingExercisesTooltip": "Speaking practice",
"speakingExercisesTooltip": "Speaking",
"noChatsFoundHereYet": "No chats found here yet",
"endNow": "End now",
"setDuration": "Set duration",
"activityEnded": "Thats a wrap for this activity! Big thanks to everyone for chatting, learning, and making this space so lively. Language grows with conversation, and every word exchanged brings us closer to confidence and fluency.\n\nKeep practicing, stay curious, and dont be shy to keep the conversation going!",
"duration": "Duration",
"transcriptionFailed": "Failed to transcribe audio",
"aUserIsKnocking": "1 user is requesting to join your space",
@ -5034,4 +5032,4 @@
},
"failedToFetchTranscription": "Failed to fetch transcription",
"deleteEmptySpaceDesc": "The space will be deleted for all participants. This action cannot be undone."
}
}

View file

@ -33,7 +33,7 @@ import 'package:fluffychat/pages/chat_details/chat_details.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart';
import 'package:fluffychat/pangea/analytics_misc/gain_points_animation.dart';
import 'package:fluffychat/pangea/analytics_misc/level_up.dart';
import 'package:fluffychat/pangea/analytics_misc/level_up/level_up_banner.dart';
import 'package:fluffychat/pangea/analytics_misc/put_analytics_controller.dart';
import 'package:fluffychat/pangea/bot/utils/bot_name.dart';
import 'package:fluffychat/pangea/chat/utils/unlocked_morphs_snackbar.dart';
@ -1856,7 +1856,10 @@ class ChatController extends State<ChatPageWithRoom>
}
}
void pinEvent() {
// #Pangea
// void pinEvent() {
Future<void> pinEvent() async {
// Pangea#
final pinnedEventIds = room.pinnedEventIds;
final selectedEventIds = selectedEvents.map((e) => e.eventId).toSet();
final unpin = selectedEventIds.length == 1 &&
@ -1866,10 +1869,16 @@ class ChatController extends State<ChatPageWithRoom>
} else {
pinnedEventIds.addAll(selectedEventIds);
}
showFutureLoadingDialog(
// #Pangea
// showFutureLoadingDialog(
// context: context,
// future: () => room.setPinnedEvents(pinnedEventIds),
// );
await showFutureLoadingDialog(
context: context,
future: () => room.setPinnedEvents(pinnedEventIds),
);
// Pangea#
}
Timer? _storeInputTimeoutTimer;

View file

@ -1,7 +1,6 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:matrix/matrix.dart';
import 'package:scroll_to_index/scroll_to_index.dart';
import 'package:fluffychat/config/app_config.dart';
@ -11,9 +10,7 @@ import 'package:fluffychat/pages/chat/events/message.dart';
import 'package:fluffychat/pages/chat/seen_by_row.dart';
import 'package:fluffychat/pages/chat/typing_indicators.dart';
import 'package:fluffychat/pangea/activity_planner/activity_plan_message.dart';
import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart';
import 'package:fluffychat/pangea/events/extensions/pangea_event_extension.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart';
import 'package:fluffychat/utils/account_config.dart';
import 'package:fluffychat/utils/matrix_sdk_extensions/filtered_timeline_extension.dart';
import 'package:fluffychat/utils/platform_infos.dart';
@ -43,30 +40,6 @@ class ChatEventList extends StatelessWidget {
final horizontalPadding = FluffyThemes.isColumnMode(context) ? 8.0 : 0.0;
final events = timeline.events.filterByVisibleInGui();
// #Pangea
if (timeline.room.activityPlan?.endAt != null &&
timeline.room.activityPlan!.endAt!.isBefore(DateTime.now())) {
final eventIndex = events.indexWhere(
(e) => e.originServerTs.isBefore(
timeline.room.activityPlan!.endAt!,
),
);
if (eventIndex != -1) {
events.insert(
eventIndex,
Event(
type: PangeaEventTypes.activityPlanEnd,
eventId: timeline.room.client.generateUniqueTransactionId(),
senderId: timeline.room.client.userID!,
originServerTs: timeline.room.activityPlan!.endAt!,
room: timeline.room,
content: {},
),
);
}
}
// Pangea#
final animateInEventIndex = controller.animateInEventIndex;
// create a map of eventId --> index to greatly improve performance of

View file

@ -13,11 +13,9 @@ import 'package:fluffychat/pages/chat/chat_app_bar_list_tile.dart';
import 'package:fluffychat/pages/chat/chat_app_bar_title.dart';
import 'package:fluffychat/pages/chat/chat_event_list.dart';
import 'package:fluffychat/pages/chat/pinned_events.dart';
import 'package:fluffychat/pangea/activities/pinned_activity_message.dart';
import 'package:fluffychat/pangea/chat/widgets/chat_input_bar.dart';
import 'package:fluffychat/pangea/chat/widgets/chat_input_bar_header.dart';
import 'package:fluffychat/pangea/chat/widgets/chat_view_background.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart';
import 'package:fluffychat/utils/account_config.dart';
import 'package:fluffychat/utils/localized_exception_extension.dart';
import 'package:fluffychat/widgets/future_loading_dialog.dart';
@ -190,13 +188,6 @@ class ChatView extends StatelessWidget {
if (scrollUpBannerEventId != null) {
appbarBottomHeight += ChatAppBarListTile.fixedHeight;
}
// #Pangea
if (controller.room.activityPlan != null &&
controller.room.activityPlan!.endAt != null &&
controller.room.activityPlan!.endAt!.isAfter(DateTime.now())) {
appbarBottomHeight += ChatAppBarListTile.fixedHeight;
}
// Pangea#
return Scaffold(
appBar: AppBar(
actionsIconTheme: IconThemeData(
@ -235,9 +226,6 @@ class ChatView extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
children: [
PinnedEvents(controller),
// #Pangea
PinnedActivityMessage(controller),
// Pangea#
if (scrollUpBannerEventId != null)
ChatAppBarListTile(
leading: IconButton(

View file

@ -77,6 +77,8 @@ class AudioPlayerState extends State<AudioPlayerWidget> {
// #Pangea
StreamSubscription? _onAudioPositionChanged;
StreamSubscription? _onAudioStateChanged;
double playbackSpeed = 1.0;
// Pangea#
@override
@ -175,6 +177,9 @@ class AudioPlayerState extends State<AudioPlayerWidget> {
: matrix.audioPlayer;
if (currentPlayer != null) {
// #Pangea
currentPlayer.setSpeed(playbackSpeed);
// Pangea#
if (currentPlayer.isAtEndPosition) {
currentPlayer.seek(Duration.zero);
} else if (currentPlayer.playing) {
@ -204,28 +209,37 @@ class AudioPlayerState extends State<AudioPlayerWidget> {
// #Pangea
// if (!kIsWeb) {
if (!kIsWeb && matrixFile != null) {
// Pangea#
final tempDir = await getTemporaryDirectory();
final fileName = Uri.encodeComponent(
// #Pangea
// widget.event.attachmentOrThumbnailMxcUrl()!.pathSegments.last,
widget.event!.attachmentOrThumbnailMxcUrl()!.pathSegments.last,
if (!kIsWeb) {
if (matrixFile != null) {
// Pangea#
);
file = File('${tempDir.path}/${fileName}_${matrixFile.name}');
final tempDir = await getTemporaryDirectory();
final fileName = Uri.encodeComponent(
// #Pangea
// widget.event.attachmentOrThumbnailMxcUrl()!.pathSegments.last,
widget.event!.attachmentOrThumbnailMxcUrl()!.pathSegments.last,
// Pangea#
);
file = File('${tempDir.path}/${fileName}_${matrixFile.name}');
await file.writeAsBytes(matrixFile.bytes);
await file.writeAsBytes(matrixFile.bytes);
if (Platform.isIOS &&
matrixFile.mimeType.toLowerCase() == 'audio/ogg') {
Logs().v('Convert ogg audio file for iOS...');
final convertedFile = File('${file.path}.caf');
if (await convertedFile.exists() == false) {
OpusCaf().convertOpusToCaf(file.path, convertedFile.path);
if (Platform.isIOS &&
matrixFile.mimeType.toLowerCase() == 'audio/ogg') {
Logs().v('Convert ogg audio file for iOS...');
final convertedFile = File('${file.path}.caf');
if (await convertedFile.exists() == false) {
OpusCaf().convertOpusToCaf(file.path, convertedFile.path);
}
file = convertedFile;
}
file = convertedFile;
// #Pangea
} else if (widget.matrixFile != null) {
final tempDir = await getTemporaryDirectory();
file = File('${tempDir.path}/${widget.matrixFile!.name}');
await file.writeAsBytes(widget.matrixFile!.bytes);
}
// Pangea#
}
setState(() {
@ -250,6 +264,7 @@ class AudioPlayerState extends State<AudioPlayerWidget> {
final audioPlayer = matrix.audioPlayer = AudioPlayer();
// #Pangea
audioPlayer.setSpeed(playbackSpeed);
_onAudioPositionChanged?.cancel();
_onAudioPositionChanged =
matrix.audioPlayer!.positionStream.listen((state) {
@ -306,7 +321,22 @@ class AudioPlayerState extends State<AudioPlayerWidget> {
void _toggleSpeed() async {
final audioPlayer = matrix.audioPlayer;
// #Pangea
// if (audioPlayer == null) return;
switch (playbackSpeed) {
case 1.0:
setState(() => playbackSpeed = 0.75);
case 0.75:
setState(() => playbackSpeed = 0.5);
case 0.5:
setState(() => playbackSpeed = 1.25);
case 1.25:
setState(() => playbackSpeed = 1.5);
default:
setState(() => playbackSpeed = 1.0);
}
if (audioPlayer == null) return;
// Pangea#
switch (audioPlayer.speed) {
// #Pangea
// case 1.0:
@ -599,7 +629,7 @@ class AudioPlayerState extends State<AudioPlayerWidget> {
height: 20,
child: Center(
child: Text(
'${audioPlayer?.speed.toString() ?? 1}x',
'${audioPlayer?.speed.toString() ?? playbackSpeed}x',
style: TextStyle(
color: widget.color,
fontSize: 9,

View file

@ -179,7 +179,9 @@ class HtmlMessage extends StatelessWidget {
// #Pangea
List<PangeaToken>? get tokens =>
pangeaMessageEvent?.messageDisplayRepresentation?.tokens;
pangeaMessageEvent?.messageDisplayRepresentation?.tokens
?.where((t) => t.pos != "PUNCT")
.toList();
PangeaToken? getToken(
String text,
@ -385,6 +387,8 @@ class HtmlMessage extends StatelessWidget {
overlayController: overlayController,
isTransitionAnimation: isTransitionAnimation,
);
final fontSize = renderer.fontSize(context) ?? this.fontSize;
// Pangea#
switch (node.localName) {
@ -500,10 +504,7 @@ class HtmlMessage extends StatelessWidget {
avatar: user.avatarUrl,
uri: href,
outerContext: context,
// #Pangea
// fontSize: fontSize,
fontSize: renderer.fontSize(context) ?? fontSize,
// Pangea#
fontSize: fontSize,
color: linkStyle.color,
// #Pangea
userId: user.id,
@ -524,10 +525,7 @@ class HtmlMessage extends StatelessWidget {
avatar: room?.avatar,
uri: href,
outerContext: context,
// #Pangea
// fontSize: fontSize,
fontSize: renderer.fontSize(context) ?? fontSize,
// Pangea#
fontSize: fontSize,
color: linkStyle.color,
),
);

View file

@ -9,9 +9,7 @@ import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pages/chat/chat.dart';
import 'package:fluffychat/pages/chat/events/room_creation_state_event.dart';
import 'package:fluffychat/pangea/activities/activity_state_event.dart';
import 'package:fluffychat/pangea/common/widgets/pressable_button.dart';
import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart';
import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/utils/date_time_extension.dart';
import 'package:fluffychat/utils/file_description.dart';
@ -123,22 +121,6 @@ class Message extends StatelessWidget {
if (event.type == EventTypes.RoomCreate) {
return RoomCreationStateEvent(event: event);
}
// #Pangea
if (event.type == PangeaEventTypes.activityPlan) {
final state = event.room.getState(PangeaEventTypes.activityPlan);
if (state == null || state is! Event) {
return const SizedBox.shrink();
}
return state.originServerTs == event.originServerTs
? ActivityStateEvent(event: event)
: const SizedBox();
}
if (event.type == PangeaEventTypes.activityPlanEnd) {
return const ActivityFinishedEvent();
}
// Pangea#
return StateMessage(event);
}

View file

@ -587,7 +587,6 @@ class ChatListController extends State<ChatList>
if (space != null) {
chatListHandleSpaceTap(
context,
this,
space,
);
}
@ -669,6 +668,10 @@ class ChatListController extends State<ChatList>
_activeSpaceId =
widget.activeSpaceId == 'clear' ? null : widget.activeSpaceId;
WidgetsBinding.instance.addPostFrameCallback((_) {
_joinInvitedSpaces();
});
// Pangea#
super.initState();
@ -685,6 +688,16 @@ class ChatListController extends State<ChatList>
: setActiveSpace(widget.activeSpaceId!);
}
}
Future<void> _joinInvitedSpaces() async {
final invitedSpaces = Matrix.of(context).client.rooms.where(
(r) => r.isSpace && r.membership == Membership.invite,
);
for (final space in invitedSpaces) {
await showInviteDialog(space, context);
}
}
// Pangea#
@override

View file

@ -35,35 +35,6 @@ class InvitationSelectionController extends State<InvitationSelection> {
String? get roomId => widget.roomId;
// #Pangea
final viewportKey = GlobalKey();
final participantListItemHeight = 72.0;
final goToChatButtonHeight = 50.0;
final shareButtonsHeight = 150.0;
final padding = 16.0 * 2;
final fixedParticipantHeight = 72.0;
double? viewportHeight;
double get availableHeight =>
(viewportHeight ?? 0) -
goToChatButtonHeight -
shareButtonsHeight -
padding;
bool showShareButtons(int numParticipants) =>
(fixedParticipantHeight * numParticipants) < availableHeight;
@override
initState() {
WidgetsBinding.instance.addPostFrameCallback((_) {
final context = viewportKey.currentContext;
if (context == null) return;
final renderBox = context.findRenderObject() as RenderBox;
final size = renderBox.size;
setState(() => viewportHeight = size.height);
});
super.initState();
}
List<User>? get participants {
final room = Matrix.of(context).client.getRoomById(roomId!);

View file

@ -8,12 +8,10 @@ import 'package:matrix/matrix.dart';
import 'package:universal_html/html.dart' as html;
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pages/invitation_selection/invitation_selection.dart';
import 'package:fluffychat/pangea/analytics_misc/level_display_name.dart';
import 'package:fluffychat/pangea/chat_settings/constants/room_settings_constants.dart';
import 'package:fluffychat/pangea/chat_settings/widgets/space_invite_buttons.dart';
import 'package:fluffychat/pangea/common/config/environment.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart';
import 'package:fluffychat/pangea/spaces/constants/space_constants.dart';
@ -107,165 +105,153 @@ class InvitationSelectionView extends StatelessWidget {
// #Pangea
withScrolling: false,
// Pangea#
child: Stack(
alignment: Alignment.bottomCenter,
child: Column(
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: SizedBox(
width: 450,
child: CachedNetworkImage(
imageUrl:
"${AppConfig.assetsBaseURL}/${RoomSettingsConstants.referFriendAsset}",
errorWidget: (context, url, error) => const SizedBox(),
placeholder: (context, url) => const Center(
child: CircularProgressIndicator.adaptive(),
// #Pangea
// padding: const EdgeInsets.all(16.0),
padding: const EdgeInsets.only(
bottom: 16.0,
left: 16.0,
right: 16.0,
),
// Pangea#
child: TextField(
textInputAction: TextInputAction.search,
decoration: InputDecoration(
filled: true,
fillColor: theme.colorScheme.secondaryContainer,
border: OutlineInputBorder(
borderSide: BorderSide.none,
borderRadius: BorderRadius.circular(99),
),
hintStyle: TextStyle(
color: theme.colorScheme.onPrimaryContainer,
fontWeight: FontWeight.normal,
),
// #Pangea
hintText: L10n.of(context).inviteStudentByUserName,
// hintText: L10n.of(context).inviteContactToGroup(groupName),
// Pangea#
prefixIcon: controller.loading
? const Padding(
padding: EdgeInsets.symmetric(
vertical: 10.0,
horizontal: 12,
),
child: SizedBox.square(
dimension: 24,
child: CircularProgressIndicator.adaptive(
strokeWidth: 2,
),
),
)
: const Icon(Icons.search_outlined),
),
onChanged: controller.searchUserWithCoolDown,
),
),
Column(
children: [
Padding(
// #Pangea
// padding: const EdgeInsets.all(16.0),
padding: const EdgeInsets.only(
bottom: 16.0,
left: 16.0,
right: 16.0,
),
// Pangea#
child: TextField(
textInputAction: TextInputAction.search,
decoration: InputDecoration(
filled: true,
fillColor: theme.colorScheme.secondaryContainer,
border: OutlineInputBorder(
borderSide: BorderSide.none,
borderRadius: BorderRadius.circular(99),
),
hintStyle: TextStyle(
color: theme.colorScheme.onPrimaryContainer,
fontWeight: FontWeight.normal,
),
// #Pangea
hintText: L10n.of(context).inviteStudentByUserName,
// hintText: L10n.of(context).inviteContactToGroup(groupName),
// Pangea#
prefixIcon: controller.loading
? const Padding(
padding: EdgeInsets.symmetric(
vertical: 10.0,
horizontal: 12,
),
child: SizedBox.square(
dimension: 24,
// #Pangea
// StreamBuilder<Object>(
Expanded(
child: StreamBuilder<Object>(
// stream: room.client.onRoomState.stream
// .where((update) => update.roomId == room.id),
stream: room.client.onRoomState.stream
.where((update) => update.roomId == room.id)
.rateLimit(const Duration(seconds: 1)),
// Pangea#
builder: (context, snapshot) {
final participants =
room.getParticipants().map((user) => user.id).toSet();
return controller.foundProfiles.isNotEmpty
? ListView.builder(
// #Pangea
// physics: const NeverScrollableScrollPhysics(),
// shrinkWrap: true,
// Pangea#
itemCount: controller.foundProfiles.length,
itemBuilder: (BuildContext context, int i) =>
_InviteContactListTile(
profile: controller.foundProfiles[i],
isMember: participants.contains(
controller.foundProfiles[i].userId,
),
onTap: () => controller.inviteAction(
context,
controller.foundProfiles[i].userId,
controller.foundProfiles[i].displayName ??
controller
.foundProfiles[i].userId.localpart ??
L10n.of(context).user,
),
),
)
: FutureBuilder<List<User>>(
future: controller.getContacts(context),
builder: (BuildContext context, snapshot) {
if (!snapshot.hasData) {
return const Center(
child: CircularProgressIndicator.adaptive(
strokeWidth: 2,
),
),
)
: const Icon(Icons.search_outlined),
),
onChanged: controller.searchUserWithCoolDown,
),
),
// #Pangea
// StreamBuilder<Object>(
Expanded(
key: controller.viewportKey,
child: StreamBuilder<Object>(
// stream: room.client.onRoomState.stream
// .where((update) => update.roomId == room.id),
stream: room.client.onRoomState.stream
.where((update) => update.roomId == room.id)
.rateLimit(const Duration(seconds: 1)),
// Pangea#
builder: (context, snapshot) {
final participants =
room.getParticipants().map((user) => user.id).toSet();
return controller.foundProfiles.isNotEmpty
? ListView.builder(
);
}
final contacts = snapshot.data!;
return ListView.builder(
// #Pangea
// physics: const NeverScrollableScrollPhysics(),
// shrinkWrap: true,
// Pangea#
itemCount: controller.foundProfiles.length,
itemBuilder: (BuildContext context, int i) =>
_InviteContactListTile(
profile: controller.foundProfiles[i],
isMember: participants.contains(
controller.foundProfiles[i].userId,
),
onTap: () => controller.inviteAction(
context,
controller.foundProfiles[i].userId,
controller.foundProfiles[i].displayName ??
controller
.foundProfiles[i].userId.localpart ??
L10n.of(context).user,
),
),
)
: FutureBuilder<List<User>>(
future: controller.getContacts(context),
builder: (BuildContext context, snapshot) {
if (!snapshot.hasData) {
return const Center(
child: CircularProgressIndicator.adaptive(
strokeWidth: 2,
// itemCount: contacts.length,
// itemBuilder: (BuildContext context, int i) =>
// _InviteContactListTile(
itemCount: contacts.length + 1,
itemBuilder: (BuildContext context, int i) {
if (i == contacts.length) {
return Padding(
padding: const EdgeInsets.all(16.0),
child: SizedBox(
width: 450,
child: CachedNetworkImage(
imageUrl:
"${AppConfig.assetsBaseURL}/${RoomSettingsConstants.referFriendAsset}",
errorWidget: (context, url, error) =>
const SizedBox(),
placeholder: (context, url) =>
const Center(
child: CircularProgressIndicator
.adaptive(),
),
),
),
);
}
final contacts = snapshot.data!;
return ListView.builder(
// #Pangea
// physics: const NeverScrollableScrollPhysics(),
// shrinkWrap: true,
// itemCount: contacts.length,
// itemBuilder: (BuildContext context, int i) =>
// _InviteContactListTile(
itemCount: contacts.length + 1,
itemBuilder: (BuildContext context, int i) {
if (i == contacts.length) {
final showButtons = controller
.showShareButtons(contacts.length);
return AnimatedOpacity(
duration:
FluffyThemes.animationDuration,
opacity: showButtons ? 1.0 : 0.0,
child: SpaceInviteButtons(room: room),
);
}
return _InviteContactListTile(
// Pangea#
user: contacts[i],
profile: Profile(
avatarUrl: contacts[i].avatarUrl,
displayName: contacts[i].displayName ??
contacts[i].id.localpart ??
L10n.of(context).user,
userId: contacts[i].id,
),
isMember:
participants.contains(contacts[i].id),
onTap: () => controller.inviteAction(
context,
contacts[i].id,
contacts[i].displayName ??
contacts[i].id.localpart ??
L10n.of(context).user,
),
);
},
return _InviteContactListTile(
// Pangea#
user: contacts[i],
profile: Profile(
avatarUrl: contacts[i].avatarUrl,
displayName: contacts[i].displayName ??
contacts[i].id.localpart ??
L10n.of(context).user,
userId: contacts[i].id,
),
isMember:
participants.contains(contacts[i].id),
onTap: () => controller.inviteAction(
context,
contacts[i].id,
contacts[i].displayName ??
contacts[i].id.localpart ??
L10n.of(context).user,
),
);
},
);
},
),
),
],
},
);
},
),
),
Padding(
padding: const EdgeInsets.all(16.0),
@ -355,6 +341,8 @@ class _InviteContactListTile extends StatelessWidget {
style: const TextStyle(
fontSize: 12.0,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
LevelDisplayName(userId: profile.userId),
],

View file

@ -87,7 +87,14 @@ class SettingsView extends StatelessWidget {
return Row(
children: [
Padding(
padding: const EdgeInsets.all(32.0),
// #Pangea
// padding: const EdgeInsets.all(32.0),
padding: const EdgeInsets.only(
top: 32.0,
bottom: 32.0,
left: 12.0,
),
// Pangea#
child: Stack(
children: [
Avatar(

View file

@ -1,62 +0,0 @@
import 'dart:async';
import 'package:flutter/material.dart';
class ActivityAwareBuilder extends StatefulWidget {
final DateTime? deadline;
final Widget Function(bool) builder;
const ActivityAwareBuilder({
super.key,
required this.builder,
this.deadline,
});
@override
State<ActivityAwareBuilder> createState() => ActivityAwareBuilderState();
}
class ActivityAwareBuilderState extends State<ActivityAwareBuilder> {
Timer? _timer;
@override
void initState() {
super.initState();
_setTimer();
}
@override
void didUpdateWidget(covariant ActivityAwareBuilder oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.deadline != widget.deadline) {
_setTimer();
}
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
void _setTimer() {
final now = DateTime.now();
final delay = widget.deadline?.difference(now);
if (delay != null && delay > Duration.zero) {
_timer?.cancel();
_timer = Timer(delay, () {
_timer?.cancel();
_timer = null;
if (mounted) setState(() {});
});
}
}
@override
Widget build(BuildContext context) {
return widget.builder(
widget.deadline != null && widget.deadline!.isAfter(DateTime.now()),
);
}
}

View file

@ -1,3 +0,0 @@
class ActivityConstants {
static const String activityFinishedAsset = "EndActivityMsg.png";
}

View file

@ -1,282 +0,0 @@
import 'package:flutter/material.dart';
import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/l10n/l10n.dart';
class ActivityDurationPopup extends StatefulWidget {
final Duration initialValue;
const ActivityDurationPopup({
super.key,
required this.initialValue,
});
@override
State<ActivityDurationPopup> createState() => ActivityDurationPopupState();
}
class ActivityDurationPopupState extends State<ActivityDurationPopup> {
final TextEditingController _daysController = TextEditingController();
final TextEditingController _hoursController = TextEditingController();
final TextEditingController _minutesController = TextEditingController();
String? error;
final List<Duration> _durations = [
const Duration(minutes: 15),
const Duration(minutes: 30),
const Duration(minutes: 45),
const Duration(minutes: 60),
const Duration(hours: 1, minutes: 30),
const Duration(hours: 2),
const Duration(hours: 24),
const Duration(days: 2),
const Duration(days: 7),
];
@override
void initState() {
super.initState();
_daysController.text = widget.initialValue.inDays.toString();
_hoursController.text =
widget.initialValue.inHours.remainder(24).toString();
_minutesController.text =
widget.initialValue.inMinutes.remainder(60).toString();
_daysController.addListener(() => setState(() => error = null));
_hoursController.addListener(() => setState(() => error = null));
_minutesController.addListener(() => setState(() => error = null));
}
@override
void dispose() {
_daysController.dispose();
_hoursController.dispose();
_minutesController.dispose();
super.dispose();
}
void _setDuration({int? days, int? hours, int? minutes}) {
setState(() {
if (days != null) _daysController.text = days.toString();
if (hours != null) _hoursController.text = hours.toString();
if (minutes != null) _minutesController.text = minutes.toString();
});
}
String _formatDuration(Duration duration) {
final days = duration.inDays;
final hours = duration.inHours.remainder(24);
final minutes = duration.inMinutes.remainder(60);
final List<String> parts = [];
if (days > 0) parts.add("${days}d");
if (hours > 0) parts.add("${hours}h");
if (minutes > 0) parts.add("${minutes}m");
if (parts.isEmpty) return "0m";
return parts.join(" ");
}
Duration get _duration {
final days = int.tryParse(_daysController.text) ?? 0;
final hours = int.tryParse(_hoursController.text) ?? 0;
final minutes = int.tryParse(_minutesController.text) ?? 0;
return Duration(days: days, hours: hours, minutes: minutes);
}
void _submit() {
final days = int.tryParse(_daysController.text);
final hours = int.tryParse(_hoursController.text);
final minutes = int.tryParse(_minutesController.text);
if (days == null || hours == null || minutes == null) {
setState(() {
error = "Invalid duration";
});
return;
}
Navigator.of(context).pop(_duration);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Dialog(
child: ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: 350.0,
),
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
spacing: 12.0,
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
L10n.of(context).setDuration,
style: const TextStyle(fontSize: 20.0, height: 1.2),
),
Column(
children: [
Container(
decoration: ShapeDecoration(
shape: RoundedRectangleBorder(
side: BorderSide(
width: 2,
color: theme.colorScheme.primary.withAlpha(100),
),
borderRadius: BorderRadius.circular(20),
),
),
padding: const EdgeInsets.only(
top: 12.0,
bottom: 12.0,
right: 24.0,
left: 8.0,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
SelectionArea(
child: Row(
spacing: 12.0,
children: [
_DatePickerInput(
type: "d",
controller: _daysController,
),
_DatePickerInput(
type: "h",
controller: _hoursController,
),
_DatePickerInput(
type: "m",
controller: _minutesController,
),
],
),
),
const Icon(
Icons.alarm,
size: 24,
),
],
),
),
AnimatedSize(
duration: FluffyThemes.animationDuration,
child: error != null
? Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(
error!,
style: TextStyle(
color: theme.colorScheme.error,
fontSize: 14.0,
),
),
)
: const SizedBox.shrink(),
),
],
),
Padding(
padding: const EdgeInsets.symmetric(
vertical: 12.0,
horizontal: 24.0,
),
child: Wrap(
spacing: 10.0,
runSpacing: 10.0,
children: _durations
.map(
(d) => InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () {
_setDuration(
days: d.inDays,
hours: d.inHours.remainder(24),
minutes: d.inMinutes.remainder(60),
);
},
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 8.0,
vertical: 0.0,
),
decoration: BoxDecoration(
color: theme.colorScheme.primaryContainer
.withAlpha(_duration == d ? 200 : 100),
borderRadius: BorderRadius.circular(12),
),
child: Text(_formatDuration(d)),
),
),
)
.toList(),
),
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
TextButton(
onPressed: _submit,
child: Text(L10n.of(context).confirm),
),
],
),
],
),
),
),
),
);
}
}
class _DatePickerInput extends StatelessWidget {
final String type;
final TextEditingController controller;
const _DatePickerInput({
required this.type,
required this.controller,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
SizedBox(
width: 35.0,
child: TextField(
controller: controller,
textAlign: TextAlign.end,
decoration: InputDecoration(
isDense: true,
border: InputBorder.none,
contentPadding: const EdgeInsets.all(0.0),
hintText: "0",
hintStyle: TextStyle(
fontSize: 20.0,
color: theme.colorScheme.onSurfaceVariant.withAlpha(100),
),
),
style: const TextStyle(
fontSize: 20.0,
),
keyboardType: TextInputType.number,
),
),
Text(type, style: const TextStyle(fontSize: 20.0)),
],
);
}
}

View file

@ -1,279 +0,0 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.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/pangea/activities/activity_constants.dart';
import 'package:fluffychat/pangea/activities/activity_duration_popup.dart';
import 'package:fluffychat/pangea/activities/countdown.dart';
import 'package:fluffychat/pangea/activity_planner/activity_plan_model.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart';
import 'package:fluffychat/widgets/future_loading_dialog.dart';
import 'package:fluffychat/widgets/mxc_image.dart';
class ActivityStateEvent extends StatefulWidget {
final Event event;
const ActivityStateEvent({required this.event, super.key});
@override
State<ActivityStateEvent> createState() => ActivityStateEventState();
}
class ActivityStateEventState extends State<ActivityStateEvent> {
Timer? _timer;
@override
void initState() {
super.initState();
final now = DateTime.now();
final delay = activityPlan?.endAt != null
? activityPlan!.endAt!.difference(now)
: null;
if (delay != null && delay > Duration.zero) {
_timer = Timer(delay, () {
setState(() {});
});
}
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
ActivityPlanModel? get activityPlan {
try {
return ActivityPlanModel.fromJson(widget.event.content);
} catch (e) {
return null;
}
}
@override
Widget build(BuildContext context) {
if (activityPlan == null) {
return const SizedBox.shrink();
}
final theme = Theme.of(context);
final isColumnMode = FluffyThemes.isColumnMode(context);
final double imageWidth = isColumnMode ? 240.0 : 175.0;
return Center(
child: Container(
constraints: const BoxConstraints(
maxWidth: 400.0,
),
margin: const EdgeInsets.all(18.0),
child: Column(
spacing: 12.0,
children: [
Container(
padding: const EdgeInsets.all(16.0),
decoration: BoxDecoration(
color: theme.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(18),
),
child: AnimatedSize(
duration: FluffyThemes.animationDuration,
child: Text(
activityPlan!.markdown,
style: TextStyle(
color: theme.colorScheme.onPrimaryContainer,
fontSize:
AppConfig.fontSizeFactor * AppConfig.messageFontSize,
),
),
),
),
AnimatedSize(
duration: FluffyThemes.animationDuration,
child: IntrinsicHeight(
child: Row(
spacing: 12.0,
children: [
Container(
height: imageWidth,
width: imageWidth,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: activityPlan!.imageURL != null
? activityPlan!.imageURL!.startsWith("mxc")
? MxcImage(
uri: Uri.parse(
activityPlan!.imageURL!,
),
width: imageWidth,
height: imageWidth,
cacheKey: activityPlan!.bookmarkId,
fit: BoxFit.cover,
)
: CachedNetworkImage(
imageUrl: activityPlan!.imageURL!,
fit: BoxFit.cover,
placeholder: (context, url) => const Center(
child: CircularProgressIndicator(),
),
errorWidget: (
context,
url,
error,
) =>
const SizedBox(),
)
: const SizedBox(),
),
),
Expanded(
child: Column(
spacing: 9.0,
mainAxisSize: MainAxisSize.max,
children: [
Expanded(
child: SizedBox.expand(
child: ElevatedButton(
style: ElevatedButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
backgroundColor:
theme.colorScheme.primaryContainer,
foregroundColor:
theme.colorScheme.onPrimaryContainer,
),
onPressed: () async {
final Duration? duration = await showDialog(
context: context,
builder: (context) {
return ActivityDurationPopup(
initialValue: activityPlan?.duration ??
const Duration(days: 1),
);
},
);
if (duration == null) return;
showFutureLoadingDialog(
context: context,
future: () =>
widget.event.room.sendActivityPlan(
activityPlan!.copyWith(
endAt: DateTime.now().add(duration),
duration: duration,
),
),
);
},
child: CountDown(
deadline: activityPlan!.endAt,
iconSize: 20.0,
textSize: 16.0,
),
),
),
), // Optional spacing between buttons
Expanded(
child: SizedBox.expand(
child: ElevatedButton(
style: ElevatedButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
backgroundColor: theme.colorScheme.error,
foregroundColor: theme.colorScheme.onPrimary,
),
onPressed: () {
showFutureLoadingDialog(
context: context,
future: () =>
widget.event.room.sendActivityPlan(
activityPlan!.copyWith(
endAt: DateTime.now(),
duration: Duration.zero,
),
),
);
},
child: Text(
L10n.of(context).endNow,
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
),
),
),
],
),
),
],
),
),
),
],
),
),
);
}
}
class ActivityFinishedEvent extends StatelessWidget {
const ActivityFinishedEvent({super.key});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Center(
child: Container(
constraints: const BoxConstraints(
maxWidth: 400.0,
),
margin: const EdgeInsets.all(18.0),
child: Container(
padding: const EdgeInsets.all(24.0),
decoration: BoxDecoration(
color: theme.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(18),
),
child: Column(
spacing: 12.0,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
L10n.of(context).activityEnded,
style: TextStyle(
color: theme.colorScheme.onPrimaryContainer,
fontSize: 16.0,
),
),
CachedNetworkImage(
width: 120.0,
imageUrl:
"${AppConfig.assetsBaseURL}/${ActivityConstants.activityFinishedAsset}",
fit: BoxFit.cover,
placeholder: (context, url) => const Center(
child: CircularProgressIndicator(),
),
errorWidget: (context, url, error) => const SizedBox(),
),
],
),
),
),
);
}
}

View file

@ -1,98 +0,0 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:fluffychat/l10n/l10n.dart';
class CountDown extends StatefulWidget {
final DateTime? deadline;
final double? iconSize;
final double? textSize;
const CountDown({
super.key,
required this.deadline,
this.iconSize,
this.textSize,
});
@override
State<CountDown> createState() => CountDownState();
}
class CountDownState extends State<CountDown> {
Timer? _timer;
@override
void initState() {
super.initState();
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
setState(() {});
});
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
String? _formatDuration(Duration duration) {
final days = duration.inDays;
final hours = duration.inHours.remainder(24);
final minutes = duration.inMinutes.remainder(60);
final seconds = duration.inSeconds.remainder(60);
final List<String> parts = [];
if (days > 0) parts.add("${days}d");
if (hours > 0) parts.add("${hours}h");
if (minutes > 0) parts.add("${minutes}m");
if (seconds > 0 && minutes <= 0) parts.add("${seconds}s");
if (parts.isEmpty) return null;
return parts.join(" ");
}
Duration? get _remainingTime {
if (widget.deadline == null) {
return null;
}
final now = DateTime.now();
return widget.deadline!.difference(now);
}
@override
Widget build(BuildContext context) {
final remainingTime = _remainingTime;
final durationString = _formatDuration(remainingTime ?? Duration.zero);
return ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: 250.0,
),
child: Row(
spacing: 4.0,
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.timer_outlined,
size: widget.iconSize ?? 28.0,
),
Flexible(
child: Text(
remainingTime != null &&
remainingTime >= Duration.zero &&
durationString != null
? durationString
: L10n.of(context).duration,
style: TextStyle(fontSize: widget.textSize ?? 20),
),
),
],
),
);
}
}

View file

@ -1,100 +0,0 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:collection/collection.dart';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/pages/chat/chat.dart';
import 'package:fluffychat/pages/chat/chat_app_bar_list_tile.dart';
import 'package:fluffychat/pangea/activities/activity_aware_builder.dart';
import 'package:fluffychat/pangea/activities/activity_duration_popup.dart';
import 'package:fluffychat/pangea/activities/countdown.dart';
import 'package:fluffychat/pangea/activity_planner/activity_plan_model.dart';
import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart';
import 'package:fluffychat/widgets/future_loading_dialog.dart';
class PinnedActivityMessage extends StatelessWidget {
final ChatController controller;
const PinnedActivityMessage(this.controller, {super.key});
Future<void> _scrollToEvent() async {
final eventId = _activityPlanEvent?.eventId;
if (eventId != null) controller.scrollToEventId(eventId);
}
Event? get _activityPlanEvent => controller.timeline?.events.firstWhereOrNull(
(event) => event.type == PangeaEventTypes.activityPlan,
);
ActivityPlanModel? get _activityPlan => controller.room.activityPlan;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return ActivityAwareBuilder(
deadline: _activityPlan?.endAt,
builder: (isActive) {
if (!isActive || _activityPlan == null) {
return const SizedBox.shrink();
}
return ChatAppBarListTile(
title: _activityPlan!.title,
leading: IconButton(
splashRadius: 18,
iconSize: 18,
color: theme.colorScheme.onSurfaceVariant,
icon: const Icon(Icons.push_pin),
onPressed: () {},
),
trailing: Padding(
padding: const EdgeInsets.only(right: 16.0),
child: InkWell(
borderRadius: BorderRadius.circular(20),
onTap: () async {
final Duration? duration = await showDialog(
context: context,
builder: (context) {
return ActivityDurationPopup(
initialValue:
_activityPlan?.duration ?? const Duration(days: 1),
);
},
);
if (duration == null) return;
showFutureLoadingDialog(
context: context,
future: () => controller.room.sendActivityPlan(
_activityPlan!.copyWith(
endAt: DateTime.now().add(duration),
duration: duration,
),
),
);
},
child: Container(
padding: const EdgeInsets.all(4.0),
decoration: BoxDecoration(
color: theme.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(20),
),
child: CountDown(
deadline: _activityPlan!.endAt,
iconSize: 16.0,
textSize: 14.0,
),
),
),
),
onTap: _scrollToEvent,
);
},
);
}
}

View file

@ -73,7 +73,7 @@ class ActivityGeneratorState extends State<ActivityGenerator> {
ActivitySettingRequestSchema get req => ActivitySettingRequestSchema(
langCode:
MatrixState.pangeaController.languageController.userL2?.langCode ??
MatrixState.pangeaController.languageController.userL1?.langCode ??
LanguageKeys.defaultLanguage,
);

View file

@ -221,7 +221,7 @@ class ActivitySuggestionsAreaState extends State<ActivitySuggestionsArea> {
),
AnimatedSize(
duration: FluffyThemes.animationDuration,
child: _timeout
child: (_timeout || !_loading && cards.isEmpty)
? Padding(
padding: const EdgeInsets.all(8.0),
child: RichText(
@ -236,8 +236,10 @@ class ActivitySuggestionsAreaState extends State<ActivitySuggestionsArea> {
),
const TextSpan(text: " "),
TextSpan(
text:
L10n.of(context).activitySuggestionTimeoutMessage,
text: _timeout
? L10n.of(context)
.activitySuggestionTimeoutMessage
: L10n.of(context).oopsSomethingWentWrong,
),
],
),

View file

@ -8,6 +8,7 @@ import 'package:fluffychat/pangea/analytics_misc/analytics_constants.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_use_model.dart';
import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart';
import 'package:fluffychat/pangea/common/config/environment.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
import 'package:fluffychat/pangea/morphs/get_grammar_copy.dart';
@ -35,7 +36,8 @@ class ConstructListModel {
/// [D] is the "compression factor". It determines how quickly
/// or slowly the level grows relative to XP
final double D = 1500;
final double D = Environment.isStagingEnvironment ? 500 : 1500;
List<ConstructIdentifier> unlockedLemmas(
ConstructTypeEnum type, {

View file

@ -22,6 +22,7 @@ import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart';
import 'package:fluffychat/pangea/learning_settings/models/language_model.dart';
import 'package:fluffychat/pangea/practice_activities/practice_selection_repo.dart';
import 'package:fluffychat/widgets/matrix.dart';
/// A minimized version of AnalyticsController that get the logged in user's analytics
class GetAnalyticsController extends BaseController {
@ -455,10 +456,13 @@ class GetAnalyticsController extends BaseController {
// int diffXP = maxXP - minXP;
// if (diffXP < 0) diffXP = 0;
Future<ConstructSummary?> getConstructSummaryFromStateEvent() async {
ConstructSummary? getConstructSummaryFromStateEvent() {
try {
final Room? analyticsRoom = _client.analyticsRoomLocal(_l2!);
if (analyticsRoom == null) return null;
if (analyticsRoom == null) {
debugPrint("Analytics room is null");
return null;
}
final state =
analyticsRoom.getState(PangeaEventTypes.constructSummary, '');
if (state == null) return null;
@ -492,23 +496,41 @@ class GetAnalyticsController extends BaseController {
}
// extract construct use message bodies for analytics
List<String?>? constructUseMessageContentBodies = [];
final Map<String, Set<String>> useEventIds = {};
for (final use in constructUseOfCurrentLevel) {
try {
final useMessage = await use.getEvent(_client);
final useMessageBody = useMessage?.content["body"];
if (useMessageBody is String) {
constructUseMessageContentBodies.add(useMessageBody);
} else {
constructUseMessageContentBodies.add(null);
}
} catch (e) {
constructUseMessageContentBodies.add(null);
}
if (use.metadata.roomId == null) continue;
if (use.metadata.eventId == null) continue;
useEventIds[use.metadata.roomId!] ??= {};
useEventIds[use.metadata.roomId!]!.add(use.metadata.eventId!);
}
if (constructUseMessageContentBodies.length !=
constructUseOfCurrentLevel.length) {
constructUseMessageContentBodies = null;
final List<String?> constructUseMessageContentBodies = [];
for (final entry in useEventIds.entries) {
final String roomId = entry.key;
final room = _client.getRoomById(roomId);
if (room == null) continue;
final List<String?> messageBodies = [];
for (final eventId in entry.value) {
try {
final Event? event = await room.getEventById(eventId);
if (event?.content["body"] is! String) continue;
final String body = event?.content["body"] as String;
if (body.isEmpty) continue;
messageBodies.add(body);
} catch (e, s) {
debugPrint("Error getting event by ID: $e");
ErrorHandler.logError(
e: e,
s: s,
data: {
'roomId': roomId,
'eventId': eventId,
},
);
continue;
}
}
constructUseMessageContentBodies.addAll(messageBodies);
}
final request = ConstructSummaryRequest(
@ -521,6 +543,10 @@ class GetAnalyticsController extends BaseController {
final response = await ConstructRepo.generateConstructSummary(request);
summary = response.summary;
summary.levelVocabConstructs = MatrixState
.pangeaController.getAnalytics.constructListModel.vocabLemmas;
summary.levelGrammarConstructs = MatrixState
.pangeaController.getAnalytics.constructListModel.grammarLemmas;
} catch (e) {
debugPrint("Error generating level up analytics: $e");
ErrorHandler.logError(e: e, data: {'e': e});

View file

@ -1,517 +0,0 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:audioplayers/audioplayers.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/analytics_misc/analytics_constants.dart';
import 'package:fluffychat/pangea/analytics_misc/learning_skills_enum.dart';
import 'package:fluffychat/pangea/common/config/environment.dart';
import 'package:fluffychat/pangea/common/utils/overlay.dart';
import 'package:fluffychat/pangea/constructs/construct_repo.dart';
import 'package:fluffychat/widgets/matrix.dart';
class LevelUpConstants {
static const String starFileName = "star.png";
static const String dinoLevelUPFileName = "DinoBot-Congratulate.png";
}
class LevelUpUtil {
static Future<void> showLevelUpDialog(
int level,
int prevLevel,
BuildContext context,
) async {
final player = AudioPlayer();
final snackbarRegex = RegExp(r'_snackbar$');
while (MatrixState.pAnyState.activeOverlays
.any((overlayId) => snackbarRegex.hasMatch(overlayId))) {
await Future.delayed(const Duration(milliseconds: 100));
}
player
.play(
UrlSource(
"${AppConfig.assetsBaseURL}/${AnalyticsConstants.levelUpAudioFileName}",
),
)
.then(
(_) => Future.delayed(
const Duration(seconds: 2),
() => player.dispose(),
),
);
OverlayUtil.showOverlay(
overlayKey: "level_up_notification",
context: context,
child: LevelUpBanner(
level: level,
prevLevel: prevLevel,
),
transformTargetId: '',
position: OverlayPositionEnum.top,
backDropToDismiss: false,
closePrevOverlay: false,
canPop: false,
);
}
}
class LevelUpBanner extends StatefulWidget {
final int level;
final int prevLevel;
const LevelUpBanner({
required this.level,
required this.prevLevel,
super.key,
});
@override
LevelUpBannerState createState() => LevelUpBannerState();
}
class LevelUpBannerState extends State<LevelUpBanner>
with TickerProviderStateMixin {
late AnimationController _slideController;
late Animation<Offset> _slideAnimation;
late AnimationController _sizeController;
late Animation<double> _sizeAnimation;
bool _showDetails = false;
bool _showedDetails = false;
ConstructSummary? _constructSummary;
String? _error;
@override
void initState() {
super.initState();
_setConstructSummary();
_slideController = AnimationController(
vsync: this,
duration: FluffyThemes.animationDuration,
);
_slideAnimation = Tween<Offset>(
begin: const Offset(0, -1),
end: Offset.zero,
).animate(
CurvedAnimation(
parent: _slideController,
curve: Curves.easeOut,
),
);
_sizeController = AnimationController(
vsync: this,
duration: FluffyThemes.animationDuration,
);
_sizeAnimation = Tween<double>(
begin: 0,
end: 1,
).animate(
CurvedAnimation(
parent: _sizeController,
curve: Curves.easeOut,
),
);
_slideController.forward();
Future.delayed(const Duration(seconds: 15), () async {
if (mounted && !_showedDetails) _close();
});
}
@override
void dispose() {
_slideController.dispose();
_sizeController.dispose();
super.dispose();
}
Future<void> _setConstructSummary() async {
try {
_constructSummary = await MatrixState.pangeaController.getAnalytics
.generateLevelUpAnalytics(
widget.level,
widget.prevLevel,
);
} catch (e) {
_error = e.toString();
}
}
Future<void> _close() async {
await _slideController.reverse();
MatrixState.pAnyState.closeOverlay("level_up_notification");
}
int _skillsPoints(LearningSkillsEnum skill) {
switch (skill) {
case LearningSkillsEnum.writing:
return _constructSummary?.writingConstructScore ?? 0;
case LearningSkillsEnum.reading:
return _constructSummary?.readingConstructScore ?? 0;
case LearningSkillsEnum.speaking:
return _constructSummary?.speakingConstructScore ?? 0;
case LearningSkillsEnum.hearing:
return _constructSummary?.hearingConstructScore ?? 0;
default:
return 0;
}
}
Future<void> _toggleDetails() async {
if (!Environment.isStagingEnvironment) return;
FocusScope.of(context).unfocus();
if (mounted) {
setState(() {
_showDetails = !_showDetails;
if (_showDetails && !_showedDetails) {
_showedDetails = true;
}
});
await (_showDetails
? _sizeController.forward()
: _sizeController.reverse());
if (!_showDetails) {
await Future.delayed(
const Duration(milliseconds: 300),
() async {
if (!mounted) return;
_close();
},
);
}
}
}
@override
Widget build(BuildContext context) {
final style = FluffyThemes.isColumnMode(context)
? Theme.of(context).textTheme.titleLarge?.copyWith(
color: Theme.of(context).colorScheme.onSecondaryContainer,
fontWeight: FontWeight.bold,
letterSpacing: 0.5,
)
: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Theme.of(context).colorScheme.onSecondaryContainer,
fontWeight: FontWeight.bold,
letterSpacing: 0.5,
);
return SafeArea(
child: Material(
color: Colors.transparent,
child: Stack(
children: [
SlideTransition(
position: _slideAnimation,
child: Align(
alignment: Alignment.topCenter,
child: ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: 600.0,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
GestureDetector(
onPanUpdate: (details) {
if (details.delta.dy < -10) _close();
},
onTap: _toggleDetails,
child: Container(
margin: const EdgeInsets.only(
top: 16,
),
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.secondaryContainer,
borderRadius: BorderRadius.circular(8),
),
padding: const EdgeInsets.symmetric(
vertical: 16,
horizontal: 24,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
spacing: 8.0,
children: [
Flexible(
child: RichText(
text: TextSpan(
children: [
TextSpan(
text: L10n.of(context)
.congratulationsOnReaching(
widget.level,
),
style: style,
),
TextSpan(
text: " ",
style: style,
),
WidgetSpan(
child: CachedNetworkImage(
imageUrl:
"${AppConfig.assetsBaseURL}/${LevelUpConstants.starFileName}",
height: 24,
width: 24,
),
),
],
),
),
),
Row(
children: [
if (Environment.isStagingEnvironment)
AnimatedSize(
duration: FluffyThemes.animationDuration,
child: _error == null
? FluffyThemes.isColumnMode(context)
? ElevatedButton(
style: IconButton.styleFrom(
padding: const EdgeInsets
.symmetric(
vertical: 4.0,
horizontal: 16.0,
),
),
onPressed: _toggleDetails,
child: Text(
L10n.of(context).details,
),
)
: SizedBox(
width: 32.0,
height: 32.0,
child: Center(
child: IconButton(
icon: const Icon(
Icons.info_outline,
),
style:
IconButton.styleFrom(
padding:
const EdgeInsets
.all(
4.0,
),
),
onPressed: _toggleDetails,
constraints:
const BoxConstraints(),
),
),
)
: Row(
children: [
Tooltip(
message: L10n.of(context)
.oopsSomethingWentWrong,
child: Icon(
Icons.error,
color: Theme.of(context)
.colorScheme
.error,
),
),
],
),
),
IconButton(
icon: const Icon(Icons.close),
onPressed: _close,
),
],
),
],
),
),
),
SizeTransition(
sizeFactor: _sizeAnimation,
child: Container(
constraints: BoxConstraints(
maxHeight:
MediaQuery.of(context).size.height * 0.75,
),
margin: const EdgeInsets.only(
top: 4.0,
),
decoration: BoxDecoration(
color: Colors.black,
borderRadius: BorderRadius.circular(8),
),
padding: const EdgeInsets.all(16),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
spacing: 24.0,
children: [
Table(
columnWidths: const {
0: IntrinsicColumnWidth(),
1: FlexColumnWidth(),
2: IntrinsicColumnWidth(),
},
defaultVerticalAlignment:
TableCellVerticalAlignment.middle,
children: [
...LearningSkillsEnum.values
.where(
(v) =>
v.isVisible && _skillsPoints(v) > -1,
)
.map((skill) {
return TableRow(
children: [
Padding(
padding: const EdgeInsets.symmetric(
vertical: 9.0,
horizontal: 18.0,
),
child: Icon(
skill.icon,
size: 25,
color: Colors.white,
),
),
Padding(
padding: const EdgeInsets.symmetric(
vertical: 9.0,
horizontal: 18.0,
),
child: Text(
skill.tooltip(context),
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.white,
),
textAlign: TextAlign.center,
),
),
Padding(
padding: const EdgeInsets.symmetric(
vertical: 9.0,
horizontal: 18.0,
),
child: Text(
"+ ${_skillsPoints(skill)} XP",
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.white,
),
textAlign: TextAlign.center,
),
),
],
);
}),
],
),
CachedNetworkImage(
imageUrl:
"${AppConfig.assetsBaseURL}/${LevelUpConstants.dinoLevelUPFileName}",
width: 400,
fit: BoxFit.cover,
),
if (_constructSummary?.textSummary != null)
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.secondaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Text(
_constructSummary!.textSummary,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Theme.of(context)
.colorScheme
.onSecondaryContainer,
),
textAlign: TextAlign.center,
),
),
const SizedBox(
height: 24,
),
// Share button, currently no functionality
// ElevatedButton(
// onPressed: () {
// // Add share functionality
// },
// style: ElevatedButton.styleFrom(
// backgroundColor: Colors.white,
// foregroundColor: Colors.black,
// padding: const EdgeInsets.symmetric(
// vertical: 12,
// horizontal: 24,
// ),
// shape: RoundedRectangleBorder(
// borderRadius: BorderRadius.circular(8),
// ),
// ),
// child: const Row(
// mainAxisSize: MainAxisSize
// .min,
// children: [
// Text(
// "Share with Friends",
// style: TextStyle(
// fontSize: 16,
// fontWeight: FontWeight.bold,
// ),
// ),
// SizedBox(
// width: 8,
// ),
// Icon(
// Icons.ios_share,
// size: 20,
// ),
// ),
// ),
// ),
],
),
),
),
),
],
),
),
),
),
],
),
),
);
}
}

View file

@ -0,0 +1,270 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:audioplayers/audioplayers.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/pangea/analytics_misc/analytics_constants.dart';
import 'package:fluffychat/pangea/analytics_misc/level_up/level_up_manager.dart';
import 'package:fluffychat/pangea/analytics_misc/level_up/level_up_popup.dart';
import 'package:fluffychat/pangea/common/config/environment.dart';
import 'package:fluffychat/pangea/common/utils/overlay.dart';
import 'package:fluffychat/widgets/matrix.dart';
class LevelUpConstants {
static const String starFileName = "star.png";
static const String dinoLevelUPFileName = "DinoBot-Congratulate.png";
}
class LevelUpUtil {
static Future<void> showLevelUpDialog(
int level,
int prevLevel,
BuildContext context,
) async {
// Remove delay since GetAnalyticsController._onLevelUp is already async
final player = AudioPlayer();
// Wait for any existing snackbars to dismiss
await _waitForSnackbars(context);
await player.play(
UrlSource(
"${AppConfig.assetsBaseURL}/${AnalyticsConstants.levelUpAudioFileName}",
),
);
if (!context.mounted) return;
await OverlayUtil.showOverlay(
overlayKey: "level_up_notification",
context: context,
child: LevelUpBanner(
level: level,
prevLevel: prevLevel,
backButtonOverride: IconButton(
icon: const Icon(Icons.close),
onPressed: () {
MatrixState.pAnyState.closeOverlay("level_up_notification");
},
),
),
transformTargetId: '',
position: OverlayPositionEnum.top,
backDropToDismiss: false,
closePrevOverlay: false,
canPop: false,
);
await Future.delayed(const Duration(seconds: 2));
player.dispose();
}
static Future<void> _waitForSnackbars(BuildContext context) async {
final snackbarRegex = RegExp(r'_snackbar$');
while (MatrixState.pAnyState.activeOverlays
.any((id) => snackbarRegex.hasMatch(id))) {
await Future.delayed(const Duration(milliseconds: 100));
}
}
}
class LevelUpBanner extends StatefulWidget {
final int level;
final int prevLevel;
final Widget? backButtonOverride;
const LevelUpBanner({
required this.level,
required this.prevLevel,
required this.backButtonOverride,
super.key,
});
@override
LevelUpBannerState createState() => LevelUpBannerState();
}
class LevelUpBannerState extends State<LevelUpBanner>
with TickerProviderStateMixin {
late AnimationController _slideController;
late Animation<Offset> _slideAnimation;
bool _showedDetails = false;
@override
void initState() {
super.initState();
LevelUpManager.instance.preloadAnalytics(
context,
widget.level,
widget.prevLevel,
);
_slideController = AnimationController(
vsync: this,
duration: FluffyThemes.animationDuration,
);
_slideAnimation = Tween<Offset>(
begin: const Offset(0, -1),
end: Offset.zero,
).animate(
CurvedAnimation(
parent: _slideController,
curve: Curves.easeOut,
),
);
_slideController.forward();
Future.delayed(const Duration(seconds: 10), () async {
if (mounted && !_showedDetails) {
_close();
}
});
}
Future<void> _close() async {
await _slideController.reverse();
MatrixState.pAnyState.closeOverlay("level_up_notification");
}
@override
void dispose() {
_slideController.dispose();
super.dispose();
}
Future<void> _toggleDetails() async {
await _close();
LevelUpManager.instance.markPopupSeen();
_showedDetails = true;
FocusScope.of(context).unfocus();
await showDialog(
context: context,
builder: (context) => const LevelUpPopup(),
);
}
@override
Widget build(BuildContext context) {
final isColumnMode = FluffyThemes.isColumnMode(context);
final style = isColumnMode
? Theme.of(context).textTheme.titleLarge?.copyWith(
color: AppConfig.gold,
fontWeight: FontWeight.bold,
letterSpacing: 0.5,
)
: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: AppConfig.gold,
fontWeight: FontWeight.bold,
letterSpacing: 0.5,
);
return SafeArea(
child: Material(
type: MaterialType.transparency,
child: SlideTransition(
position: _slideAnimation,
child: LayoutBuilder(
builder: (context, constraints) {
return GestureDetector(
onPanUpdate: (details) {
if (details.delta.dy < -10) _close();
},
onTap: _toggleDetails,
child: Container(
padding: const EdgeInsets.symmetric(
vertical: 16.0,
horizontal: 4.0,
),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
border: Border(
bottom: BorderSide(
color: AppConfig.gold.withAlpha(200),
width: 2.0,
),
),
borderRadius: const BorderRadius.only(
bottomLeft: Radius.circular(AppConfig.borderRadius),
bottomRight: Radius.circular(AppConfig.borderRadius),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// Spacer for symmetry
SizedBox(
width: constraints.maxWidth >= 600 ? 120.0 : 65.0,
),
// Centered content
Expanded(
child: Padding(
padding: EdgeInsets.symmetric(
horizontal: isColumnMode ? 16.0 : 8.0,
),
child: Wrap(
spacing: 16.0,
alignment: WrapAlignment.center,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Text(
"Level up",
style: style,
overflow: TextOverflow.ellipsis,
),
CachedNetworkImage(
imageUrl:
"${AppConfig.assetsBaseURL}/${LevelUpConstants.starFileName}",
height: 24,
width: 24,
),
],
),
),
),
SizedBox(
width: constraints.maxWidth >= 600 ? 120.0 : 65.0,
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
if (Environment.isStagingEnvironment)
SizedBox(
width: 32.0,
height: 32.0,
child: Center(
child: IconButton(
icon: const Icon(Icons.arrow_drop_down),
style: IconButton.styleFrom(
padding: const EdgeInsets.all(4.0),
),
onPressed: _toggleDetails,
constraints: const BoxConstraints(),
color:
Theme.of(context).colorScheme.onSurface,
),
),
),
],
),
),
],
),
),
);
},
),
),
),
);
}
}

View file

@ -0,0 +1,133 @@
import 'package:flutter/material.dart';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/pangea/analytics_misc/client_analytics_extension.dart';
import 'package:fluffychat/pangea/constructs/construct_repo.dart';
import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart';
import 'package:fluffychat/pangea/learning_settings/models/language_model.dart';
import 'package:fluffychat/widgets/matrix.dart';
class LevelUpManager {
// Singleton instance so analytics can be generated when level up is initiated, and be ready by the time user clicks on banner
static final LevelUpManager instance = LevelUpManager._internal();
LevelUpManager._internal();
int prevLevel = 0;
int level = 0;
int prevGrammar = 0;
int nextGrammar = 0;
int prevVocab = 0;
int nextVocab = 0;
String? userL2Code;
ConstructSummary? constructSummary;
bool hasSeenPopup = false;
bool shouldAutoPopup = false;
String? error;
Future<void> preloadAnalytics(
BuildContext context,
int level,
int prevLevel,
) async {
this.level = level;
this.prevLevel = prevLevel;
//For on route change behavior, if added in the future
shouldAutoPopup = true;
nextGrammar = MatrixState
.pangeaController.getAnalytics.constructListModel.grammarLemmas;
nextVocab = MatrixState
.pangeaController.getAnalytics.constructListModel.vocabLemmas;
userL2Code = MatrixState.pangeaController.languageController
.activeL2Code()
?.toUpperCase();
getConstructFromLevelUp();
final LanguageModel? l2 =
MatrixState.pangeaController.languageController.userL2;
final Room? analyticsRoom =
MatrixState.pangeaController.matrixState.client.analyticsRoomLocal(l2!);
if (analyticsRoom != null) {
// How to get all summary events in the timeline
final timeline = await analyticsRoom.getTimeline();
final summaryEvents = timeline.events
.where(
(e) => e.type == PangeaEventTypes.constructSummary,
)
.map(
(e) => ConstructSummary.fromJson(e.content),
)
.toList();
//Find previous summary to get grammar constructs and vocab numbers from
final lastSummary = summaryEvents
.where((summary) => summary.upperLevel == prevLevel)
.toList()
.isNotEmpty
? summaryEvents
.firstWhere((summary) => summary.upperLevel == prevLevel)
: null;
//Set grammar and vocab from last level summary, if there is one. Otherwise set to placeholder data
if (lastSummary != null &&
lastSummary.levelVocabConstructs != null &&
lastSummary.levelGrammarConstructs != null) {
prevVocab = lastSummary.levelVocabConstructs!;
prevGrammar = lastSummary.levelGrammarConstructs!;
} else {
prevGrammar = (nextGrammar / prevLevel) as int;
prevVocab = (nextVocab / prevLevel) as int;
}
}
}
//for testing, just fetch last level up from saved analytics
void getConstructFromButton() {
constructSummary = MatrixState.pangeaController.getAnalytics
.getConstructSummaryFromStateEvent();
debugPrint(
"Last saved construct summary from analytics controller function: ${constructSummary?.toJson()}",
);
}
//for getting real level up data when leveled up
void getConstructFromLevelUp() async {
try {
constructSummary = await MatrixState.pangeaController.getAnalytics
.generateLevelUpAnalytics(
prevLevel,
level,
);
} catch (e) {
error = e.toString();
}
}
void markPopupSeen() {
hasSeenPopup = true;
shouldAutoPopup = false;
}
void reset() {
hasSeenPopup = false;
shouldAutoPopup = false;
prevLevel = 0;
level = 0;
prevGrammar = 0;
nextGrammar = 0;
prevVocab = 0;
nextVocab = 0;
constructSummary = null;
error = null;
}
}

View file

@ -0,0 +1,521 @@
import 'dart:async';
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:animated_flip_counter/animated_flip_counter.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:confetti/confetti.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:matrix/matrix_api_lite/generated/model.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/analytics_misc/learning_skills_enum.dart';
import 'package:fluffychat/pangea/analytics_misc/level_up/level_up_banner.dart';
import 'package:fluffychat/pangea/analytics_misc/level_up/level_up_manager.dart';
import 'package:fluffychat/pangea/analytics_misc/level_up/rain_confetti.dart';
import 'package:fluffychat/pangea/analytics_summary/progress_bar/level_bar.dart';
import 'package:fluffychat/pangea/analytics_summary/progress_bar/progress_bar_details.dart';
import 'package:fluffychat/pangea/common/widgets/full_width_dialog.dart';
import 'package:fluffychat/pangea/constructs/construct_repo.dart';
import 'package:fluffychat/widgets/avatar.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:fluffychat/widgets/mxc_image.dart';
class LevelUpPopup extends StatelessWidget {
const LevelUpPopup({
super.key,
});
@override
Widget build(BuildContext context) {
return FullWidthDialog(
maxWidth: 400,
maxHeight: 800,
dialogContent: Scaffold(
appBar: AppBar(
centerTitle: true,
title: kIsWeb
? Text(
L10n.of(context).youHaveLeveledUp,
style: const TextStyle(
color: AppConfig.gold,
fontWeight: FontWeight.w600,
),
)
: null,
),
body: LevelUpPopupContent(
prevLevel: LevelUpManager.instance.prevLevel,
level: LevelUpManager.instance.level,
),
),
);
}
}
class LevelUpPopupContent extends StatefulWidget {
final int prevLevel;
final int level;
const LevelUpPopupContent({
super.key,
required this.prevLevel,
required this.level,
});
@override
State<LevelUpPopupContent> createState() => _LevelUpPopupContentState();
}
class _LevelUpPopupContentState extends State<LevelUpPopupContent>
with SingleTickerProviderStateMixin {
late int _endGrammar;
late int _endVocab;
final int _startGrammar = LevelUpManager.instance.prevGrammar;
final int _startVocab = LevelUpManager.instance.prevVocab;
Timer? _summaryPollTimer;
final String? _error = LevelUpManager.instance.error;
String language = LevelUpManager.instance.userL2Code ?? "N/A";
late final AnimationController _controller;
late final ConfettiController _confettiController;
bool _hasBlastedConfetti = false;
final Duration _animationDuration = const Duration(seconds: 5);
Uri? avatarUrl;
late final Future<Profile> profile;
int displayedLevel = -1;
late ConstructSummary? _constructSummary;
@override
void initState() {
super.initState();
LevelUpManager.instance.markPopupSeen();
displayedLevel = widget.prevLevel;
_confettiController =
ConfettiController(duration: const Duration(seconds: 1));
_endGrammar = LevelUpManager.instance.nextGrammar;
_endVocab = LevelUpManager.instance.nextVocab;
_constructSummary = LevelUpManager.instance.constructSummary;
// Poll for constructSummary if not available
if (_constructSummary == null) {
_summaryPollTimer =
Timer.periodic(const Duration(milliseconds: 300), (timer) {
final summary = LevelUpManager.instance.constructSummary;
if (summary != null) {
setState(() {
_constructSummary = summary;
});
timer.cancel();
}
});
}
final client = Matrix.of(context).client;
client.fetchOwnProfile().then((profile) {
setState(() {
avatarUrl = profile.avatarUrl;
});
});
_controller = AnimationController(
duration: _animationDuration,
vsync: this,
);
// halfway through the animation, switch to the new level
_controller.addListener(() {
if (_controller.value >= 0.5 && displayedLevel == widget.prevLevel) {
setState(() {
displayedLevel = widget.level;
});
}
});
_controller.addListener(() {
if (_controller.value >= 0.5 && !_hasBlastedConfetti) {
//_confettiController.play();
_hasBlastedConfetti = true;
rainConfetti(context);
}
});
_controller.forward();
}
@override
void dispose() {
_summaryPollTimer?.cancel();
_controller.dispose();
_confettiController.dispose();
LevelUpManager.instance.reset();
stopConfetti();
super.dispose();
}
int _getSkillXP(LearningSkillsEnum skill) {
if (_constructSummary == null) return 0;
return switch (skill) {
LearningSkillsEnum.writing =>
_constructSummary?.writingConstructScore ?? 0,
LearningSkillsEnum.reading =>
_constructSummary?.readingConstructScore ?? 0,
LearningSkillsEnum.speaking =>
_constructSummary?.speakingConstructScore ?? 0,
LearningSkillsEnum.hearing =>
_constructSummary?.hearingConstructScore ?? 0,
_ => 0,
};
}
@override
@override
Widget build(BuildContext context) {
final Animation<double> progressAnimation =
Tween<double>(begin: 0, end: 1).animate(
CurvedAnimation(parent: _controller, curve: const Interval(0.0, 0.5)),
);
final Animation<int> vocabAnimation =
IntTween(begin: _startVocab, end: _endVocab).animate(
CurvedAnimation(
parent: _controller,
curve: const Interval(0.0, 0.5, curve: Curves.easeInOutQuad),
),
);
final Animation<int> grammarAnimation =
IntTween(begin: _startGrammar, end: _endGrammar).animate(
CurvedAnimation(
parent: _controller,
curve: const Interval(0.0, 0.5, curve: Curves.easeInOutQuad),
),
);
final Animation<double> skillsOpacity =
Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(
parent: _controller,
curve: const Interval(0.7, 1.0, curve: Curves.easeIn),
),
);
final Animation<double> shrinkMultiplier =
Tween<double>(begin: 1.0, end: 0.3).animate(
CurvedAnimation(
parent: _controller,
curve: const Interval(0.7, 1.0, curve: Curves.easeInOut),
),
);
final colorScheme = Theme.of(context).colorScheme;
final grammarVocabStyle = Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.primary,
);
final username =
Matrix.of(context).client.userID?.split(':').first.substring(1) ?? '';
return Stack(
children: [
SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
AnimatedBuilder(
animation: _controller,
builder: (_, __) => Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Padding(
padding: const EdgeInsets.all(24.0),
child: avatarUrl == null
? Avatar(
name: username,
showPresence: false,
size: 150 * shrinkMultiplier.value,
)
: ClipOval(
child: MxcImage(
uri: avatarUrl,
width: 150 * shrinkMultiplier.value,
height: 150 * shrinkMultiplier.value,
),
),
),
Text(
language,
style: TextStyle(
fontSize: 24 * skillsOpacity.value,
color: AppConfig.goldLight,
fontWeight: FontWeight.bold,
),
),
],
),
),
// Progress bar + Level
AnimatedBuilder(
animation: _controller,
builder: (_, __) => Row(
children: [
Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
return LevelBar(
details: const LevelBarDetails(
fillColor: AppConfig.goldLight,
currentPoints: 0,
widthMultiplier: 1,
),
progressBarDetails: ProgressBarDetails(
totalWidth: constraints.maxWidth *
progressAnimation.value,
height: 20,
borderColor: colorScheme.primary,
),
);
},
),
),
const SizedBox(width: 8),
Text(
"",
style: Theme.of(context).textTheme.titleLarge,
),
Padding(
padding: const EdgeInsets.all(8.0),
child: AnimatedFlipCounter(
value: displayedLevel,
textStyle: Theme.of(context)
.textTheme
.headlineMedium
?.copyWith(
fontWeight: FontWeight.bold,
color: AppConfig.goldLight,
),
duration: const Duration(milliseconds: 1000),
curve: Curves.easeInOut,
),
),
],
),
),
const SizedBox(height: 25),
// Vocab and grammar row
AnimatedBuilder(
animation: _controller,
builder: (_, __) => Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
"+ ${_endVocab - _startVocab}",
style: const TextStyle(
color: Colors.lightGreen,
fontWeight: FontWeight.bold,
),
),
Row(
children: [
Icon(
Symbols.dictionary,
color: colorScheme.primary,
size: 35,
),
const SizedBox(width: 8),
Text(
'${vocabAnimation.value}',
style: grammarVocabStyle,
),
],
),
],
),
const SizedBox(width: 40),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
"+ ${_endGrammar - _startGrammar}",
style: const TextStyle(
color: Colors.lightGreen,
fontWeight: FontWeight.bold,
),
),
Row(
children: [
Icon(
Symbols.toys_and_games,
color: colorScheme.primary,
size: 35,
),
const SizedBox(width: 8),
Text(
'${grammarAnimation.value}',
style: grammarVocabStyle,
),
],
),
],
),
],
),
),
const SizedBox(height: 16),
// Skills section
AnimatedBuilder(
animation: skillsOpacity,
builder: (_, __) => Opacity(
opacity: skillsOpacity.value,
child: _error == null
? Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
_buildSkillsTable(context),
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.only(top: 16),
child: Text(
_constructSummary?.textSummary ??
L10n.of(context).loadingPleaseWait,
textAlign: TextAlign.left,
style: Theme.of(context).textTheme.bodyMedium,
),
),
Padding(
padding: const EdgeInsets.all(24.0),
child: CachedNetworkImage(
imageUrl:
"${AppConfig.assetsBaseURL}/${LevelUpConstants.dinoLevelUPFileName}",
width: 400,
fit: BoxFit.cover,
),
),
],
)
// if error getting construct summary
: Row(
children: [
Tooltip(
message: L10n.of(context).oopsSomethingWentWrong,
child: Icon(
Icons.error,
color: Theme.of(context).colorScheme.error,
),
),
],
),
),
),
// Share button, currently no functionality
// ElevatedButton(
// onPressed: () {
// // Add share functionality
// },
// style: ElevatedButton.styleFrom(
// backgroundColor: Colors.white,
// foregroundColor: Colors.black,
// padding: const EdgeInsets.symmetric(
// vertical: 12,
// horizontal: 24,
// ),
// shape: RoundedRectangleBorder(
// borderRadius: BorderRadius.circular(8),
// ),
// ),
// child: const Row(
// mainAxisSize: MainAxisSize.min,
// children: [
// Text(
// "Share with Friends",
// style: TextStyle(
// fontSize: 16,
// fontWeight: FontWeight.bold,
// ),
// ),
// SizedBox(
// width: 8,
// ),
// Icon(
// Icons.ios_share,
// size: 20,
// ),
// ],
// ),
// ),
],
),
),
],
);
}
Widget _buildSkillsTable(BuildContext context) {
final visibleSkills = LearningSkillsEnum.values
.where((skill) => (_getSkillXP(skill) > -1) && skill.isVisible)
.toList();
const itemsPerRow = 4;
// chunk into rows of up to 4
final rows = <List<LearningSkillsEnum>>[
for (var i = 0; i < visibleSkills.length; i += itemsPerRow)
visibleSkills.sublist(
i,
min(i + itemsPerRow, visibleSkills.length),
),
];
return Column(
children: rows.map((row) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
crossAxisAlignment: CrossAxisAlignment.start,
children: row.map((skill) {
return Flexible(
fit: FlexFit.loose,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
skill.tooltip(context),
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 4),
Icon(
skill.icon,
size: 25,
color: Theme.of(context).colorScheme.onSurface,
),
const SizedBox(height: 4),
Text(
'+ ${_getSkillXP(skill)} XP',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: AppConfig.gold,
),
textAlign: TextAlign.center,
),
],
),
);
}).toList(),
),
);
}).toList(),
);
}
}

View file

@ -0,0 +1,121 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:confetti/confetti.dart';
import 'package:fluffychat/config/app_config.dart';
OverlayEntry? _confettiEntry;
ConfettiController? _blastController;
ConfettiController? _rainController;
void rainConfetti(BuildContext context) {
if (_confettiEntry != null) return; // Prevent duplicates
_blastController = ConfettiController(duration: const Duration(seconds: 1));
_rainController = ConfettiController(duration: const Duration(seconds: 3));
_blastController!.play();
_rainController!.play();
final screenWidth = MediaQuery.of(context).size.width;
final isSmallScreen = screenWidth < 600;
final count = isSmallScreen ? 2 : 5;
final spacing = screenWidth / (count + 1);
_confettiEntry = OverlayEntry(
builder: (context) => Stack(
children: [
// Initial center blast
Positioned(
top: 0,
left: screenWidth / 2,
child: IgnorePointer(
child: ConfettiWidget(
confettiController: _blastController!,
blastDirectionality: BlastDirectionality.explosive,
shouldLoop: false,
emissionFrequency: .02,
numberOfParticles: 40,
minimumSize: const Size(20, 20),
maximumSize: const Size(25, 25),
minBlastForce: 10,
maxBlastForce: 40,
gravity: 0.07,
colors: const [AppConfig.goldLight, AppConfig.gold],
createParticlePath: drawStar,
),
),
),
// Rain confetti from the top
...List.generate(count, (index) {
final left = spacing * (index + 1) - 10;
return Positioned(
top: -30, // Small buffer above top edge
left: left,
child: IgnorePointer(
child: ConfettiWidget(
confettiController: _rainController!,
blastDirectionality: BlastDirectionality.directional,
blastDirection: 3 * pi / 2,
shouldLoop: true,
maxBlastForce: 5,
minBlastForce: 2,
minimumSize: const Size(20, 20),
maximumSize: const Size(25, 25),
gravity: 0.07,
emissionFrequency: 0.1,
numberOfParticles: 2,
colors: const [AppConfig.goldLight, AppConfig.gold],
createParticlePath: drawStar,
),
),
);
}),
],
),
);
Overlay.of(context, rootOverlay: true).insert(_confettiEntry!);
}
void stopConfetti() {
_confettiEntry?.remove();
_confettiEntry = null;
_blastController?.dispose();
_blastController = null;
_rainController?.dispose();
_rainController = null;
}
Path drawStar(Size size) {
double degToRad(double deg) => deg * (pi / 180.0);
const numberOfPoints = 5;
final halfWidth = size.width / 2;
final externalRadius = halfWidth;
final internalRadius = halfWidth / 2.5;
final degreesPerStep = degToRad(360 / numberOfPoints);
final halfDegreesPerStep = degreesPerStep / 2;
final path = Path();
final fullAngle = degToRad(360);
path.moveTo(size.width, halfWidth);
for (double step = 0; step < fullAngle; step += degreesPerStep) {
path.lineTo(
halfWidth + externalRadius * cos(step),
halfWidth + externalRadius * sin(step),
);
path.lineTo(
halfWidth + internalRadius * cos(step + halfDegreesPerStep),
halfWidth + internalRadius * sin(step + halfDegreesPerStep),
);
}
path.close();
return path;
}

View file

@ -3,16 +3,15 @@ import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pages/chat_list/chat_list.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart';
import 'package:fluffychat/widgets/adaptive_dialogs/show_ok_cancel_alert_dialog.dart';
import 'package:fluffychat/widgets/future_loading_dialog.dart';
import 'package:fluffychat/widgets/matrix.dart';
import '../../common/utils/error_handler.dart';
Future<void> _showInviteDialog(Room room, BuildContext context) async {
Future<void> showInviteDialog(Room room, BuildContext context) async {
if (room.membership != Membership.invite) return;
final acceptInvite = await showOkCancelAlertDialog(
context: context,
title: L10n.of(context).youreInvited,
@ -23,7 +22,7 @@ Future<void> _showInviteDialog(Room room, BuildContext context) async {
cancelLabel: L10n.of(context).decline,
);
await showFutureLoadingDialog(
final resp = await showFutureLoadingDialog(
context: context,
future: () async {
if (acceptInvite == OkCancelResult.ok) {
@ -31,28 +30,25 @@ Future<void> _showInviteDialog(Room room, BuildContext context) async {
context.go(
room.isSpace ? "/rooms?spaceId=${room.id}" : "/rooms/${room.id}",
);
return;
return room.id;
} else if (acceptInvite == OkCancelResult.cancel) {
await room.leave();
}
await room.leave();
},
);
if (!resp.isError && resp.result is String) {
context.go("/rooms?spaceId=${resp.result}");
}
}
// ignore: curly_braces_in_flow_control_structures
void chatListHandleSpaceTap(
BuildContext context,
ChatListController controller,
Room space,
) {
void setActiveSpaceAndCloseChat() {
controller.setActiveSpace(space.id);
if (FluffyThemes.isColumnMode(context)) {
context.go('/rooms/${space.id}');
} else if (controller.activeChat != null &&
!space.isFirstOrSecondChild(controller.activeChat!)) {
context.go("/rooms");
}
context.go("/rooms?spaceId=${space.id}");
}
void autoJoin(Room space) {
@ -85,7 +81,7 @@ void chatListHandleSpaceTap(
justInputtedCode == space.classCode) {
// do nothing
} else {
_showInviteDialog(space, context);
showInviteDialog(space, context);
}
break;
case Membership.leave:

View file

@ -146,7 +146,7 @@ class DeleteSpaceDialogState extends State<DeleteSpaceDialog> {
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
),
Expanded(
Flexible(
child: SingleChildScrollView(
child: Builder(
builder: (context) {

View file

@ -1,137 +0,0 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:matrix/matrix.dart';
import 'package:universal_html/html.dart' as html;
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/common/config/environment.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart';
import 'package:fluffychat/pangea/spaces/constants/space_constants.dart';
class SpaceInviteButtons extends StatefulWidget {
final Room room;
// final ScrollController scrollController;
const SpaceInviteButtons({
super.key,
required this.room,
// required this.scrollController,
});
@override
SpaceInviteButtonsController createState() => SpaceInviteButtonsController();
}
class SpaceInviteButtonsController extends State<SpaceInviteButtons> {
// bool get isVisible {
// final context = (widget.key as GlobalKey).currentContext;
// if (context == null) return false;
// final renderBox = context.findRenderObject() as RenderBox;
// final position = renderBox.localToGlobal(Offset.zero);
// final size = renderBox.size;
// final screenHeight = MediaQuery.of(context).size.height;
// debugPrint("position: $position, size: $size, screenHeight: $screenHeight");
// // Check if any part of the widget is within the visible range
// return position.dy + size.height > 0 && position.dy < screenHeight;
// }
@override
void initState() {
// WidgetsBinding.instance.addPostFrameCallback(
// (_) => debugPrint("isVisible: $isVisible"),
// );
super.initState();
}
@override
Widget build(BuildContext context) {
final spaceCode = widget.room.classCode;
if (!widget.room.isSpace || spaceCode == null) {
return const SizedBox.shrink();
}
return SizedBox(
height: 150.0,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Padding(
padding: const EdgeInsets.only(
top: 16.0,
right: 16.0,
left: 16.0,
),
child: ElevatedButton(
child: Row(
spacing: 8.0,
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.share_outlined,
),
Text(L10n.of(context).shareSpaceLink),
],
),
onPressed: () async {
final String initialUrl =
kIsWeb ? html.window.origin! : Environment.frontendURL;
final link =
"$initialUrl/#/join_with_link?${SpaceConstants.classCode}=$spaceCode";
await Clipboard.setData(
ClipboardData(
text: link,
),
);
ScaffoldMessenger.of(
context,
).showSnackBar(
SnackBar(
content: Text(
L10n.of(context).copiedToClipboard,
),
),
);
},
),
),
Padding(
padding: const EdgeInsets.only(
top: 16.0,
right: 16.0,
left: 16.0,
),
child: ElevatedButton(
child: Row(
spacing: 8.0,
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.share_outlined,
),
Text(L10n.of(context).shareInviteCode(spaceCode)),
],
),
onPressed: () async {
await Clipboard.setData(ClipboardData(text: spaceCode));
ScaffoldMessenger.of(
context,
).showSnackBar(
SnackBar(
content: Text(
L10n.of(context).copiedToClipboard,
),
),
);
},
),
),
],
),
);
}
}

View file

@ -11,6 +11,8 @@ import 'package:fluffychat/widgets/matrix.dart';
class ConstructSummary {
final int upperLevel;
final int lowerLevel;
int? levelVocabConstructs;
int? levelGrammarConstructs;
final String language;
final String textSummary;
final int writingConstructScore;
@ -21,6 +23,8 @@ class ConstructSummary {
ConstructSummary({
required this.upperLevel,
required this.lowerLevel,
this.levelVocabConstructs,
this.levelGrammarConstructs,
required this.language,
required this.textSummary,
required this.writingConstructScore,
@ -33,6 +37,8 @@ class ConstructSummary {
return {
'upper_level': upperLevel,
'lower_level': lowerLevel,
'level_grammar_constructs': levelGrammarConstructs,
'level_vocab_constructs': levelVocabConstructs,
'language': language,
'text_summary': textSummary,
'writing_construct_score': writingConstructScore,
@ -46,6 +52,8 @@ class ConstructSummary {
return ConstructSummary(
upperLevel: json['upper_level'],
lowerLevel: json['lower_level'],
levelGrammarConstructs: json['level_grammar_constructs'],
levelVocabConstructs: json['level_vocab_constructs'],
language: json['language'],
textSummary: json['text_summary'],
writingConstructScore: json['writing_construct_score'],

View file

@ -26,7 +26,6 @@ class PangeaEventTypes {
static const capacity = "pangea.capacity";
static const activityPlan = "pangea.activity_plan";
static const activityPlanEnd = "pangea.activity.end";
static const userAge = "pangea.user_age";

View file

@ -268,6 +268,21 @@ class PangeaMessageEvent {
final botTranscription = SpeechToTextModel.fromJson(
Map<String, dynamic>.from(rawBotTranscription),
);
_representations?.add(
RepresentationEvent(
timeline: timeline,
parentMessageEvent: _event,
content: PangeaRepresentation(
langCode: botTranscription.langCode,
text: botTranscription.transcript.text,
originalSent: false,
originalWritten: false,
speechToText: botTranscription,
),
),
);
return botTranscription;
}
@ -398,7 +413,15 @@ class PangeaMessageEvent {
),
);
}
_representations!.add(sent);
// If originalSent has no tokens, there is not way to generate a tokens event
// and send it as a related event, since original sent has not eventID to set
// as parentEventId. In this case, it's better to generate a new representation
// with an eventID and send the related tokens event to that representation.
// This is a rare situation, and has only been seen with some bot messages.
if (sent.tokens != null) {
_representations!.add(sent);
}
} catch (err, s) {
ErrorHandler.logError(
m: "error parsing originalSent",

View file

@ -9,6 +9,7 @@ import 'package:flutter/material.dart';
import 'package:collection/collection.dart';
import 'package:html_unescape/html_unescape.dart';
import 'package:http/http.dart' as http;
import 'package:matrix/matrix.dart';
import 'package:matrix/src/utils/markdown.dart';
import 'package:sentry_flutter/sentry_flutter.dart';

View file

@ -0,0 +1,16 @@
import 'dart:math';
import 'package:matrix/matrix_api_lite/generated/model.dart';
import 'package:fluffychat/pangea/spaces/constants/space_constants.dart';
extension PangeaRoomsChunk on PublicRoomsChunk {
/// Use Random with a seed to get the default
/// avatar associated with this space
String defaultAvatar() {
final int seed = roomId.hashCode;
return SpaceConstants.publicSpaceIcons[Random(seed).nextInt(
SpaceConstants.publicSpaceIcons.length,
)];
}
}

View file

@ -277,6 +277,57 @@ extension EventsRoomExtension on Room {
}) async {
BookmarkedActivitiesRepo.save(activity);
String? imageURL = activity.imageURL;
final eventId = await pangeaSendTextEvent(
activity.markdown,
messageTag: ModelKey.messageTagActivityPlan,
);
Uint8List? bytes = avatar;
if (imageURL != null && bytes == null) {
try {
final resp = await http
.get(Uri.parse(imageURL))
.timeout(const Duration(seconds: 5));
bytes = resp.bodyBytes;
} catch (e, s) {
ErrorHandler.logError(
e: e,
s: s,
data: {
"avatarURL": imageURL,
},
);
}
}
if (bytes != null && imageURL == null) {
final url = await client.uploadContent(
bytes,
filename: filename,
);
imageURL = url.toString();
}
MatrixFile? file;
if (filename != null && bytes != null) {
file = MatrixFile(
bytes: bytes,
name: filename,
);
}
if (file != null) {
final content = <String, dynamic>{
'msgtype': file.msgType,
'body': file.name,
'filename': file.name,
'url': imageURL,
ModelKey.messageTags: ModelKey.messageTagActivityPlan,
};
await sendEvent(content);
}
if (canChangeStateEvent(PangeaEventTypes.activityPlan)) {
await client.setRoomStateWithKey(
id,
@ -284,6 +335,10 @@ extension EventsRoomExtension on Room {
"",
activity.toJson(),
);
if (eventId != null && canChangeStateEvent(EventTypes.RoomPinnedEvents)) {
await setPinnedEvents([eventId]);
}
}
}

View file

@ -1,10 +1,12 @@
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/analytics_summary/learning_progress_indicator_button.dart';
import 'package:fluffychat/pangea/extensions/pangea_rooms_chunk_extension.dart';
import 'package:fluffychat/pangea/public_spaces/public_room_bottom_sheet.dart';
import 'package:fluffychat/widgets/avatar.dart';
@ -35,14 +37,26 @@ class PublicSpaceTile extends StatelessWidget {
height: isColumnMode ? 80.0 : 58.0,
child: Row(
children: [
Avatar(
mxContent: space.avatarUrl,
name: space.name,
size: isColumnMode ? 80.0 : 58.0,
borderRadius: BorderRadius.circular(
10,
),
),
(space.avatarUrl != null)
? Avatar(
mxContent: space.avatarUrl,
name: space.name,
size: isColumnMode ? 80.0 : 58.0,
borderRadius: BorderRadius.circular(
10,
),
)
: ClipRRect(
borderRadius: BorderRadius.circular(
10,
),
child: CachedNetworkImage(
imageUrl: space.defaultAvatar(),
width: isColumnMode ? 80.0 : 58.0,
height: isColumnMode ? 80.0 : 58.0,
fit: BoxFit.cover,
),
),
Flexible(
child: Padding(
padding: const EdgeInsets.all(8.0),

View file

@ -2,7 +2,6 @@ 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';
@ -71,13 +70,6 @@ class LanguageModel {
displayName: "Unknown",
);
static LanguageModel multiLingual([BuildContext? context]) => LanguageModel(
displayName: context != null
? L10n.of(context).multiLingualSpace
: "Multilingual Space",
langCode: LanguageKeys.multiLanguage,
);
String? getDisplayName(BuildContext context) {
return displayName;
}

View file

@ -125,10 +125,12 @@ class SettingsLearningController extends State<SettingsLearning> {
if (formKey.currentState!.validate()) {
await showFutureLoadingDialog(
context: context,
future: () async => pangeaController.userController.updateProfile(
(_) => _profile,
waitForDataInSync: true,
),
future: () async => pangeaController.userController
.updateProfile(
(_) => _profile,
waitForDataInSync: true,
)
.timeout(const Duration(seconds: 15)),
);
Navigator.of(context).pop();
}

View file

@ -42,7 +42,6 @@ class PLanguageStore {
_langList = _langList.toSet().toList();
_langList.sort((a, b) => a.displayName.compareTo(b.displayName));
_langList.insert(0, LanguageModel.multiLingual());
} catch (err, stack) {
debugger(when: kDebugMode);
ErrorHandler.logError(

View file

@ -14,7 +14,6 @@ class PLanguageDropdown extends StatefulWidget {
final List<LanguageModel> languages;
final LanguageModel? initialLanguage;
final Function(LanguageModel) onChange;
final bool showMultilingual;
final bool isL2List;
final String? decorationText;
final String? error;
@ -28,7 +27,6 @@ class PLanguageDropdown extends StatefulWidget {
required this.languages,
required this.onChange,
required this.initialLanguage,
this.showMultilingual = false,
this.decorationText,
this.isL2List = false,
this.error,
@ -132,15 +130,6 @@ class PLanguageDropdownState extends State<PLanguageDropdown> {
),
),
items: [
if (widget.showMultilingual)
DropdownMenuItem(
value: LanguageModel.multiLingual(context),
enabled: widget.enabled,
child: LanguageDropDownEntry(
languageModel: LanguageModel.multiLingual(context),
isL2List: widget.isL2List,
),
),
...sortedLanguages.map(
(languageModel) => DropdownMenuItem(
value: languageModel,

View file

@ -80,17 +80,16 @@ class OnboardingController extends State<Onboarding> {
Future<void> startChatWithBot() async {
final resp = await showFutureLoadingDialog<String>(
context: context,
future: () => Matrix.of(context).client.createRoom(
invite: [BotName.byEnvironment],
isDirect: true,
preset: CreateRoomPreset.trustedPrivateChat,
initialState: [
BotOptionsModel(mode: BotMode.directChat).toStateEvent,
RoomDefaults.defaultPowerLevels(
Matrix.of(context).client.userID!,
),
],
future: () => Matrix.of(context).client.startDirectChat(
BotName.byEnvironment,
preset: CreateRoomPreset.trustedPrivateChat,
initialState: [
BotOptionsModel(mode: BotMode.directChat).toStateEvent,
RoomDefaults.defaultPowerLevels(
Matrix.of(context).client.userID!,
),
],
),
);
if (resp.isError) return;
context.go("/rooms/${resp.result}");

View file

@ -22,6 +22,8 @@ class PhoneticTranscriptionWidget extends StatefulWidget {
final bool enabled;
final VoidCallback? onTranscriptionFetched;
const PhoneticTranscriptionWidget({
super.key,
required this.text,
@ -30,6 +32,7 @@ class PhoneticTranscriptionWidget extends StatefulWidget {
this.iconSize,
this.iconColor,
this.enabled = true,
this.onTranscriptionFetched,
});
@override
@ -51,6 +54,17 @@ class _PhoneticTranscriptionWidgetState
_fetchTranscription();
}
@override
void didUpdateWidget(
covariant PhoneticTranscriptionWidget oldWidget,
) {
super.didUpdateWidget(oldWidget);
if (oldWidget.text != widget.text ||
oldWidget.textLanguage != widget.textLanguage) {
_fetchTranscription();
}
}
Future<void> _fetchTranscription() async {
try {
setState(() {
@ -92,7 +106,12 @@ class _PhoneticTranscriptionWidgetState
},
);
} finally {
if (mounted) setState(() => _isLoading = false);
if (mounted) {
setState(() {
_isLoading = false;
widget.onTranscriptionFetched?.call();
});
}
}
}

View file

@ -1,5 +1,3 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
@ -9,7 +7,7 @@ import 'package:matrix/matrix.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/common/config/environment.dart';
import 'package:fluffychat/pangea/spaces/constants/space_constants.dart';
import 'package:fluffychat/pangea/extensions/pangea_rooms_chunk_extension.dart';
import 'package:fluffychat/utils/adaptive_bottom_sheet.dart';
import 'package:fluffychat/utils/fluffy_share.dart';
import 'package:fluffychat/widgets/avatar.dart';
@ -212,10 +210,7 @@ class PublicRoomBottomSheetState extends State<PublicRoomBottomSheet> {
: ClipRRect(
borderRadius: BorderRadius.circular(24.0),
child: CachedNetworkImage(
imageUrl: SpaceConstants
.publicSpaceIcons[Random().nextInt(
SpaceConstants.publicSpaceIcons.length,
)],
imageUrl: chunk!.defaultAvatar(),
width: 160.0,
height: 160.0,
fit: BoxFit.cover,

View file

@ -1,5 +1,3 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
@ -7,8 +5,8 @@ import 'package:matrix/matrix.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/common/widgets/pressable_button.dart';
import 'package:fluffychat/pangea/extensions/pangea_rooms_chunk_extension.dart';
import 'package:fluffychat/pangea/public_spaces/public_room_bottom_sheet.dart';
import 'package:fluffychat/pangea/spaces/constants/space_constants.dart';
import 'package:fluffychat/widgets/mxc_image.dart';
class PublicSpaceCard extends StatelessWidget {
@ -71,10 +69,7 @@ class PublicSpaceCard extends StatelessWidget {
fit: BoxFit.cover,
)
: CachedNetworkImage(
imageUrl: SpaceConstants
.publicSpaceIcons[Random().nextInt(
SpaceConstants.publicSpaceIcons.length,
)],
imageUrl: space.defaultAvatar(),
width: width,
height: width,
fit: BoxFit.cover,

View file

@ -58,7 +58,7 @@ class SubscriptionController extends BaseController {
final bool hasSubscription =
currentSubscriptionInfo?.currentSubscriptionId != null;
return hasSubscription;
return hasSubscription || _userController.inTrialWindow();
}
bool _isInitializing = false;

View file

@ -8,7 +8,6 @@ import 'package:matrix/matrix.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pages/chat/events/audio_player.dart';
import 'package:fluffychat/pangea/analytics_misc/text_loading_shimmer.dart';
import 'package:fluffychat/pangea/choreographer/widgets/igc/card_error_widget.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';
@ -96,9 +95,7 @@ class MessageAudioCardState extends State<MessageAudioCard> {
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.onPrimary,
)
: const CardErrorWidget(
error: "Null audio file in message_audio_card",
);
: const SizedBox();
}
}

View file

@ -18,8 +18,6 @@ import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dar
import 'package:fluffychat/pangea/events/event_wrappers/pangea_representation_event.dart';
import 'package:fluffychat/pangea/events/models/pangea_token_model.dart';
import 'package:fluffychat/pangea/events/models/pangea_token_text_model.dart';
import 'package:fluffychat/pangea/events/models/tokens_event_content_model.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart';
import 'package:fluffychat/pangea/lemmas/lemma_info_response.dart';
import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart';
import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart';
@ -101,6 +99,8 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
bool showSpeechTranslation = false;
String? speechTranslation;
final StreamController contentChangedStream = StreamController.broadcast();
double maxWidth = AppConfig.toolbarMinWidth;
/////////////////////////////////////
@ -121,6 +121,7 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
WidgetsBinding.instance.addPostFrameCallback(
(_) => widget.chatController.clearSelectedEvents(),
);
contentChangedStream.close();
super.dispose();
}
@ -132,7 +133,11 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
RepresentationEvent? repEvent =
pangeaMessageEvent?.messageDisplayRepresentation;
repEvent ??= await _fetchNewRepEvent();
if (repEvent == null ||
(repEvent.event == null && repEvent.tokens == null)) {
repEvent = await _fetchNewRepEvent();
}
if (repEvent?.event != null) {
await repEvent!.sendTokensEvent(
@ -142,24 +147,6 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
MatrixState.pangeaController.languageController.userL2!.langCode,
);
}
// If repEvent is originalSent but it's missing tokens, then fetch tokens.
// An edge case, but has happened with some bot message.
else if (repEvent != null &&
repEvent.tokens == null &&
repEvent.content.originalSent) {
final tokens = await repEvent.tokensGlobal(
pangeaMessageEvent!.senderId,
pangeaMessageEvent!.event.originServerTs,
);
await pangeaMessageEvent!.room.pangeaSendTextEvent(
pangeaMessageEvent!.messageDisplayText,
editEventId: pangeaMessageEvent!.eventId,
originalSent: pangeaMessageEvent!.originalSent?.content,
originalWritten: pangeaMessageEvent!.originalWritten?.content,
tokensSent: PangeaMessageTokens(tokens: tokens),
choreo: pangeaMessageEvent!.originalSent?.choreo,
);
}
// Get all the lemma infos
final messageVocabConstructIds = pangeaMessageEvent!
@ -587,7 +574,10 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
void setTranslation(String value) {
if (mounted) {
setState(() => translation = value);
setState(() {
translation = value;
contentChangedStream.add(true);
});
}
}
@ -598,12 +588,18 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
}
if (showTranslation == show) return;
setState(() => showTranslation = show);
setState(() {
showTranslation = show;
contentChangedStream.add(true);
});
}
void setSpeechTranslation(String value) {
if (mounted) {
setState(() => speechTranslation = value);
setState(() {
speechTranslation = value;
contentChangedStream.add(true);
});
}
}
@ -614,7 +610,10 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
}
if (showSpeechTranslation == show) return;
setState(() => showSpeechTranslation = show);
setState(() {
showSpeechTranslation = show;
contentChangedStream.add(true);
});
}
void setTranscription(SpeechToTextModel value) {
@ -622,13 +621,17 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
setState(() {
transcriptionError = null;
transcription = value;
contentChangedStream.add(true);
});
}
}
void setTranscriptionError(String value) {
if (mounted) {
setState(() => transcriptionError = value);
setState(() {
transcriptionError = value;
contentChangedStream.add(true);
});
}
}

View file

@ -71,6 +71,7 @@ class MessageSelectionPositionerState extends State<MessageSelectionPositioner>
Offset? _currentOffset;
StreamSubscription? _reactionSubscription;
StreamSubscription? _contentChangedSubscription;
final _animationDuration = const Duration(
milliseconds: AppConfig.overlayAnimationDuration,
@ -106,6 +107,10 @@ class MessageSelectionPositionerState extends State<MessageSelectionPositioner>
},
).listen((_) => setState(() {}));
_contentChangedSubscription = widget
.overlayController.contentChangedStream.stream
.listen(_onContentSizeChanged);
WidgetsBinding.instance.addPostFrameCallback((_) async {
await _centeredMessageCompleter.future;
if (!mounted) return;
@ -138,6 +143,7 @@ class MessageSelectionPositionerState extends State<MessageSelectionPositioner>
void dispose() {
_animationController.dispose();
_reactionSubscription?.cancel();
_contentChangedSubscription?.cancel();
MatrixState.pangeaController.matrixState.audioPlayer
?..stop()
..dispose();
@ -196,34 +202,9 @@ class MessageSelectionPositionerState extends State<MessageSelectionPositioner>
}
if (mode == ReadingAssistanceMode.selectMode) {
_overlayOffsetAnimation = Tween<Offset>(
begin: _currentOffset,
end: _adjustedOriginalMessageOffset,
).animate(
CurvedAnimation(
parent: _animationController,
curve: FluffyThemes.animationCurve,
),
)..addListener(() {
if (mounted) {
setState(() => _currentOffset = _overlayOffsetAnimation?.value);
}
});
_resetOffsetAnimation(_adjustedOriginalMessageOffset);
} else if (mode == ReadingAssistanceMode.practiceMode) {
_overlayOffsetAnimation = Tween<Offset>(
begin: _currentOffset,
end: _centeredMessageOffset!,
).animate(
CurvedAnimation(
parent: _animationController,
curve: FluffyThemes.animationCurve,
),
)..addListener(() {
if (mounted) {
setState(() => _currentOffset = _overlayOffsetAnimation?.value);
}
});
_resetOffsetAnimation(_centeredMessageOffset!);
_messageSizeAnimation = Tween<Size>(
begin: Size(
_originalMessageSize.width,
@ -244,6 +225,40 @@ class MessageSelectionPositionerState extends State<MessageSelectionPositioner>
}
}
void _onContentSizeChanged(_) {
Future.delayed(FluffyThemes.animationDuration, () {
final offset = _overlayMessageRenderBox?.localToGlobal(Offset.zero);
if (offset == null || !_overlayMessageRenderBox!.hasSize) {
return null;
}
final newOffset = _adjustedMessageOffset(
_overlayMessageRenderBox!.size,
offset,
);
if (newOffset == _currentOffset) return;
_resetOffsetAnimation(newOffset);
_animationController.forward(from: 0);
});
}
void _resetOffsetAnimation(Offset offset) {
_overlayOffsetAnimation = Tween<Offset>(
begin: _currentOffset,
end: offset,
).animate(
CurvedAnimation(
parent: _animationController,
curve: FluffyThemes.animationCurve,
),
)..addListener(() {
if (mounted) {
setState(() => _currentOffset = _overlayOffsetAnimation?.value);
}
});
}
T _runWithLogging<T>(
Function runner,
String errorMessage,
@ -326,6 +341,14 @@ class MessageSelectionPositionerState extends State<MessageSelectionPositioner>
null,
);
RenderBox? get _overlayMessageRenderBox => _runWithLogging<RenderBox?>(
() => MatrixState.pAnyState.getRenderBox(
'overlay_message_${widget.event.eventId}',
),
"Error getting overlay message render box",
null,
);
Size get _defaultMessageSize => const Size(FluffyThemes.columnWidth / 2, 100);
/// The size of the message in the chat list (as opposed to the expanded size in the center overlay)
@ -394,17 +417,28 @@ class MessageSelectionPositionerState extends State<MessageSelectionPositioner>
}
Offset get _adjustedOriginalMessageOffset {
return _adjustedMessageOffset(
_originalMessageSize,
_originalMessageOffset,
);
}
Offset _adjustedMessageOffset(
Size messageSize,
Offset messageOffset,
) {
if (_messageRenderBox == null || !_messageRenderBox!.hasSize) {
return _defaultMessageOffset;
}
final topOffset = _originalMessageOffset.dy;
final bottomOffset = _originalMessageBottomOffset -
_reactionsHeight -
_selectionButtonsHeight;
final topOffset = messageOffset.dy;
final bottomOffset =
(_mediaQuery!.size.height - topOffset - messageSize.height) -
_reactionsHeight -
_selectionButtonsHeight;
final hasHeaderOverflow = topOffset <
(_headerHeight + AppConfig.toolbarSpacing + _audioTranscriptionHeight);
final hasHeaderOverflow =
topOffset < (_headerHeight + AppConfig.toolbarSpacing);
final hasFooterOverflow =
bottomOffset < (_footerHeight + AppConfig.toolbarSpacing);
@ -416,15 +450,12 @@ class MessageSelectionPositionerState extends State<MessageSelectionPositioner>
}
if (hasHeaderOverflow) {
final difference = topOffset -
(_headerHeight +
AppConfig.toolbarSpacing +
_audioTranscriptionHeight);
final difference = topOffset - (_headerHeight + AppConfig.toolbarSpacing);
double newBottomOffset = _mediaQuery!.size.height -
_originalMessageOffset.dy +
topOffset +
difference -
_originalMessageSize.height -
messageSize.height -
_selectionButtonsHeight;
if (newBottomOffset < _footerHeight + AppConfig.toolbarSpacing) {
@ -524,12 +555,6 @@ class MessageSelectionPositionerState extends State<MessageSelectionPositioner>
return showSelectionButtons ? AppConfig.toolbarButtonsHeight : 0;
}
double get _audioTranscriptionHeight {
return widget.pangeaMessageEvent?.isAudioMessage ?? false
? AppConfig.audioTranscriptionMaxHeight
: 0;
}
bool get _hasReactions {
final reactionsEvents = widget.event.aggregatedEvents(
widget.chatController.timeline!,

View file

@ -9,6 +9,7 @@ import 'package:fluffychat/pangea/toolbar/enums/reading_assistance_mode_enum.dar
import 'package:fluffychat/pangea/toolbar/widgets/measure_render_box.dart';
import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.dart';
import 'package:fluffychat/pangea/toolbar/widgets/overlay_message.dart';
import 'package:fluffychat/widgets/matrix.dart';
class OverlayCenterContent extends StatelessWidget {
final Event event;
@ -69,6 +70,11 @@ class OverlayCenterContent extends StatelessWidget {
MeasureRenderBox(
onChange: onChangeMessageSize,
child: OverlayMessage(
key: isTransitionAnimation
? MatrixState.pAnyState
.layerLinkAndKey('overlay_message_${event.eventId}')
.key
: null,
event,
pangeaMessageEvent: pangeaMessageEvent,
immersionMode: chatController.choreographer.immersionMode,

View file

@ -109,10 +109,15 @@ class OverlayHeaderState extends State<OverlayHeader> {
icon: pinned
? const Icon(Icons.push_pin)
: const Icon(Icons.push_pin_outlined),
onPressed: controller.pinEvent,
onPressed: () {
controller
.pinEvent()
.then((_) => setState(() {}));
},
tooltip: pinned ? l10n.unpin : l10n.pinMessage,
color: theme.colorScheme.primary,
),
if (controller.canEditSelectedEvents &&
!controller.selectedEvents.first.isActivityMessage)
IconButton(

View file

@ -1,9 +1,12 @@
import 'dart:math';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.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/chat.dart';
import 'package:fluffychat/pages/chat/events/message_content.dart';
@ -19,6 +22,7 @@ import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.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
@ -149,9 +153,13 @@ class OverlayMessage extends StatelessWidget {
final transcription = showTranscription
? Container(
width: messageWidth,
constraints: const BoxConstraints(
maxHeight: AppConfig.audioTranscriptionMaxHeight,
constraints: BoxConstraints(
maxWidth: min(
FluffyThemes.columnWidth * 1.5,
MediaQuery.of(context).size.width -
(ownMessage ? 0 : Avatar.defaultSize) -
24.0,
),
),
child: Padding(
padding: const EdgeInsets.all(12.0),
@ -178,6 +186,7 @@ class OverlayMessage extends StatelessWidget {
child: Column(
spacing: 8.0,
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
SttTranscriptTokens(
model: overlayController.transcription!,
@ -197,8 +206,8 @@ class OverlayMessage extends StatelessWidget {
text: overlayController
.transcription!.transcript.text,
textLanguage: PLanguageStore.byLangCode(
pangeaMessageEvent!
.messageDisplayLangCode,
overlayController
.transcription!.langCode,
) ??
LanguageModel.unknown,
style: AppConfig.messageTextStyle(
@ -208,6 +217,9 @@ class OverlayMessage extends StatelessWidget {
iconColor: textColor,
enabled:
event.senderId != BotName.byEnvironment,
onTranscriptionFetched: () =>
overlayController.contentChangedStream
.add(true),
),
],
),
@ -226,9 +238,13 @@ class OverlayMessage extends StatelessWidget {
final translation = showTranslation || showSpeechTranslation
? Container(
width: messageWidth,
constraints: const BoxConstraints(
maxHeight: AppConfig.audioTranscriptionMaxHeight,
constraints: BoxConstraints(
maxWidth: min(
FluffyThemes.columnWidth * 1.5,
MediaQuery.of(context).size.width -
(ownMessage ? 0 : Avatar.defaultSize) -
24.0,
),
),
child: Padding(
padding: const EdgeInsets.fromLTRB(
@ -271,8 +287,6 @@ class OverlayMessage extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
if (readingAssistanceMode == ReadingAssistanceMode.transitionMode)
transcription,
if (event.relationshipType == RelationshipTypes.reply)
FutureBuilder<Event?>(
future: event.getReplyEvent(
@ -371,8 +385,6 @@ class OverlayMessage extends StatelessWidget {
],
),
),
if (readingAssistanceMode == ReadingAssistanceMode.transitionMode)
translation,
],
),
),
@ -386,26 +398,31 @@ class OverlayMessage extends StatelessWidget {
color: noBubble ? Colors.transparent : color,
borderRadius: borderRadius,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (readingAssistanceMode != ReadingAssistanceMode.transitionMode)
constraints: BoxConstraints(
maxWidth: FluffyThemes.columnWidth * 1.5,
maxHeight: maxHeight,
),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
transcription,
sizeAnimation != null
? AnimatedBuilder(
animation: sizeAnimation!,
builder: (context, child) {
return SizedBox(
height: sizeAnimation!.value.height,
width: sizeAnimation!.value.width,
child: content,
);
},
)
: content,
if (readingAssistanceMode != ReadingAssistanceMode.transitionMode)
sizeAnimation != null
? AnimatedBuilder(
animation: sizeAnimation!,
builder: (context, child) {
return SizedBox(
height: sizeAnimation!.value.height,
width: sizeAnimation!.value.width,
child: content,
);
},
)
: content,
translation,
],
],
),
),
),
);

View file

@ -1,5 +1,4 @@
import 'dart:async';
import 'dart:developer';
import 'dart:io';
import 'package:flutter/foundation.dart';
@ -126,7 +125,8 @@ class SelectModeButtonsState extends State<SelectModeButtons> {
void _clear() {
setState(() {
_audioError = null;
// 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;
});
@ -149,8 +149,10 @@ class SelectModeButtonsState extends State<SelectModeButtons> {
}
setState(
() => _selectedMode =
_selectedMode == mode && mode != SelectMode.audio ? null : mode,
() => _selectedMode = _selectedMode == mode &&
(mode != SelectMode.audio || _audioError != null)
? null
: mode,
);
if (_selectedMode == SelectMode.audio) {
@ -202,12 +204,10 @@ class SelectModeButtonsState extends State<SelectModeButtons> {
File? file;
file = File('${tempDir.path}/${_audioBytes!.name}');
await file.writeAsBytes(_audioBytes!.bytes);
setState(() => _audioFile = file);
_audioFile = file;
}
if (mounted) setState(() => _isLoadingAudio = false);
} catch (e, s) {
debugger(when: kDebugMode);
_audioError = e.toString();
ErrorHandler.logError(
e: e,
s: s,
@ -217,6 +217,7 @@ class SelectModeButtonsState extends State<SelectModeButtons> {
messageEvent?.messageDisplayLangCode,
},
);
} finally {
if (mounted) setState(() => _isLoadingAudio = false);
}
}
@ -240,7 +241,7 @@ class SelectModeButtonsState extends State<SelectModeButtons> {
matrix?.audioPlayer?.dispose();
matrix?.audioPlayer = AudioPlayer();
matrix?.voiceMessageEventId.value =
widget.overlayController.pangeaMessageEvent?.eventId;
"${widget.overlayController.pangeaMessageEvent?.eventId}_button";
_onPlayerStateChanged =
matrix?.audioPlayer?.playerStateStream.listen((state) {
@ -289,7 +290,7 @@ class SelectModeButtonsState extends State<SelectModeButtons> {
}
TtsController.stop();
matrix?.audioPlayer?.play();
await matrix?.audioPlayer?.play();
} catch (e, s) {
setState(() => _audioError = e.toString());
ErrorHandler.logError(
@ -487,25 +488,28 @@ class SelectModeButtonsState extends State<SelectModeButtons> {
spacing: 4.0,
children: [
for (final mode in modes)
Tooltip(
message: mode.tooltip(context),
child: PressableButton(
depressed: mode == _selectedMode,
borderRadius: BorderRadius.circular(20),
color: Theme.of(context).colorScheme.primaryContainer,
onPressed: () => _updateMode(mode),
playSound: true,
colorFactor: Theme.of(context).brightness == Brightness.light
? 0.55
: 0.3,
child: Container(
height: buttonSize,
width: buttonSize,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
shape: BoxShape.circle,
TooltipVisibility(
visible: (!_isError || mode != _selectedMode),
child: Tooltip(
message: mode.tooltip(context),
child: PressableButton(
depressed: mode == _selectedMode,
borderRadius: BorderRadius.circular(20),
color: Theme.of(context).colorScheme.primaryContainer,
onPressed: () => _updateMode(mode),
playSound: mode != SelectMode.audio,
colorFactor: Theme.of(context).brightness == Brightness.light
? 0.55
: 0.3,
child: Container(
height: buttonSize,
width: buttonSize,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
shape: BoxShape.circle,
),
child: icon(mode),
),
child: icon(mode),
),
),
),

View file

@ -73,15 +73,18 @@ class WordZoomWidget extends StatelessWidget {
),
),
),
Text(
token.text.content,
style: TextStyle(
fontSize: 32.0,
fontWeight: FontWeight.w600,
height: 1.2,
color: Theme.of(context).brightness == Brightness.light
? AppConfig.yellowDark
: AppConfig.yellowLight,
Flexible(
child: Text(
token.text.content,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 32.0,
fontWeight: FontWeight.w600,
height: 1.2,
color: Theme.of(context).brightness == Brightness.light
? AppConfig.yellowDark
: AppConfig.yellowLight,
),
),
),
ConstructXpWidget(

View file

@ -129,6 +129,7 @@ abstract class ClientManager {
PangeaEventTypes.userSetLemmaInfo,
EventTypes.RoomJoinRules,
PangeaEventTypes.activityPlan,
PangeaEventTypes.constructSummary,
// Pangea#
},
logLevel: kReleaseMode ? Level.warning : Level.verbose,

View file

@ -1,13 +1,36 @@
import 'package:matrix/matrix.dart';
import 'package:fluffychat/pangea/common/constants/model_keys.dart';
import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart';
import '../../config/app_config.dart';
extension VisibleInGuiExtension on List<Event> {
List<Event> filterByVisibleInGui({String? exceptionEventId}) => where(
(event) => event.isVisibleInGui || event.eventId == exceptionEventId,
).toList();
List<Event> filterByVisibleInGui({String? exceptionEventId}) {
final visibleEvents =
where((e) => e.isVisibleInGui || e.eventId == exceptionEventId)
.toList();
// Hide creation state events:
if (visibleEvents.isNotEmpty &&
visibleEvents.last.type == EventTypes.RoomCreate) {
var i = visibleEvents.length - 2;
while (i > 0) {
final event = visibleEvents[i];
if (!event.isState) break;
if (event.type == EventTypes.Encryption) {
i--;
continue;
}
if (event.type == EventTypes.RoomMember &&
event.roomMemberChangeType == RoomMemberChangeType.acceptInvite) {
i--;
continue;
}
visibleEvents.removeAt(i);
i--;
}
}
return visibleEvents;
}
}
extension IsStateExtension on Event {
@ -23,12 +46,7 @@ extension IsStateExtension on Event {
// if we enabled to hide all redacted events, don't show those
(!AppConfig.hideRedactedEvents || !redacted) &&
// if we enabled to hide all unknown events, don't show those
// #Pangea
// (!AppConfig.hideUnknownEvents || isEventTypeKnown) &&
(!AppConfig.hideUnknownEvents ||
isEventTypeKnown ||
importantStateEvents.contains(type)) &&
// Pangea#
(!AppConfig.hideUnknownEvents || isEventTypeKnown) &&
// remove state events that we don't want to render
(isState || !AppConfig.hideAllStateEvents) &&
// #Pangea
@ -64,8 +82,6 @@ extension IsStateExtension on Event {
EventTypes.RoomMember,
EventTypes.RoomTombstone,
EventTypes.CallInvite,
PangeaEventTypes.activityPlan,
PangeaEventTypes.activityPlanEnd,
};
// Pangea#
}

View file

@ -7,6 +7,7 @@ import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pages/chat_list/navi_rail_item.dart';
import 'package:fluffychat/pangea/chat_list/utils/chat_list_handle_space_tap.dart';
import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart';
import 'package:fluffychat/utils/stream_extension.dart';
import 'package:fluffychat/widgets/avatar.dart';
@ -175,7 +176,20 @@ class SpacesNavigationRail extends StatelessWidget {
return NaviRailItem(
toolTip: displayname,
isSelected: activeSpaceId == space.id,
onTap: () => onGoToSpaceId(rootSpaces[i].id),
// #Pangea
// onTap: () => onGoToSpaceId(rootSpaces[i].id),
onTap: () {
final room = client.getRoomById(rootSpaces[i].id);
if (room != null) {
chatListHandleSpaceTap(
context,
room,
);
} else {
onGoToSpaceId(rootSpaces[i].id);
}
},
// Pangea#
unreadBadgeFilter: (room) =>
spaceChildrenIds.contains(room.id),
icon: Avatar(

View file

@ -38,6 +38,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "5.3.0"
animated_flip_counter:
dependency: "direct main"
description:
name: animated_flip_counter
sha256: "73f852d84c461c3e4c1ddf320bee334dde8dba89441922ab11a8013be0b2fad1"
url: "https://pub.dev"
source: hosted
version: "0.3.4"
animations:
dependency: "direct main"
description:
@ -334,6 +342,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.0"
confetti:
dependency: "direct main"
description:
name: confetti
sha256: "79376a99648efbc3f23582f5784ced0fe239922bd1a0fb41f582051eba750751"
url: "https://pub.dev"
source: hosted
version: "0.8.0"
console:
dependency: transitive
description:

View file

@ -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.12+2
version: 4.1.12+1
environment:
sdk: ">=3.0.0 <4.0.0"
@ -21,6 +21,7 @@ dependencies:
chewie: ^1.11.3
collection: ^1.18.0
cross_file: ^0.3.4+2
confetti: ^0.8.0
cupertino_icons: any
# #Pangea
# desktop_drop: ^0.4.4
@ -135,6 +136,7 @@ dependencies:
text_to_speech:
git: https://github.com/pangeachat/text_to_speech.git
flutter_tts: ^4.2.0
animated_flip_counter: ^0.3.4
# Pangea#
dev_dependencies:

View file

@ -70,6 +70,33 @@
});
});
</script>
<!-- #Pangea -->
<script>
(function() {
const userAgent = navigator.userAgent || navigator.vendor || window.opera;
const isAndroid = /android/i.test(userAgent);
const isIOS = /iPhone|iPad|iPod/i.test(userAgent);
const isMobile = isAndroid || isIOS;
if (!isMobile) return; // Exit if not a mobile device
const appScheme = 'pangea://'; // Replace with your app's scheme
const fallbackURL = isIOS
? 'https://apps.apple.com/app/pangea-chat/id1445118630'
: 'https://play.google.com/store/apps/details?id=com.talktolearn.chat';
// Try opening the app
window.location = appScheme;
// Fallback to App Store / Play Store if not installed
setTimeout(() => {
window.location = fallbackURL;
}, 1500);
})();
</script>
<!-- Pangea# -->
<picture id="splash">
<!-- #Pangea -->
<!-- <source