Merge branch 'main' into 819-cant-type-with-it-bar

This commit is contained in:
ggurdin 2024-10-24 14:37:53 -04:00 committed by GitHub
commit 1cc4551e12
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 434 additions and 429 deletions

View file

@ -23,6 +23,8 @@ abstract class AppConfig {
static const bool allowOtherHomeservers = true;
static const bool enableRegistration = true;
static const double toolbarMaxHeight = 300.0;
static const double toolbarMinHeight = 70.0;
static const double toolbarMinWidth = 270.0;
// #Pangea
// static const Color primaryColor = Color(0xFF5625BA);
// static const Color primaryColorLight = Color(0xFFCCBDEA);

View file

@ -21,6 +21,7 @@ class AudioPlayerWidget extends StatefulWidget {
final Event? event;
final PangeaAudioFile? matrixFile;
final bool autoplay;
final Function(bool)? setIsPlayingAudio;
// Pangea#
static String? currentId;
@ -41,6 +42,7 @@ class AudioPlayerWidget extends StatefulWidget {
this.autoplay = false,
this.sectionStartMS,
this.sectionEndMS,
this.setIsPlayingAudio,
// Pangea#
super.key,
});
@ -204,8 +206,13 @@ class AudioPlayerState extends State<AudioPlayerWidget> {
if (max == null || max == Duration.zero) return;
setState(() => maxPosition = max.inMilliseconds.toDouble());
});
onPlayerStateChanged ??=
audioPlayer.playingStream.listen((_) => setState(() {}));
onPlayerStateChanged ??= audioPlayer.playingStream.listen(
(isPlaying) => setState(() {
// #Pangea
widget.setIsPlayingAudio?.call(isPlaying);
// Pangea#
}),
);
final audioFile = this.audioFile;
if (audioFile != null) {
audioPlayer.setFilePath(audioFile.path);
@ -467,7 +474,7 @@ class AudioPlayerState extends State<AudioPlayerWidget> {
borderRadius: BorderRadius.circular(2),
),
height: 32 * (waveform[i] / 1024),
width: 1.5,
width: 3,
),
],
),

View file

@ -1,18 +1,18 @@
import 'dart:async';
class BaseController<T> {
final StreamController<T> stateListener = StreamController<T>();
final StreamController<T> _stateListener = StreamController<T>();
late Stream<T> stateStream;
BaseController() {
stateStream = stateListener.stream.asBroadcastStream();
stateStream = _stateListener.stream.asBroadcastStream();
}
dispose() {
stateListener.close();
_stateListener.close();
}
setState(T data) {
stateListener.add(data);
_stateListener.add(data);
}
}

View file

@ -22,7 +22,7 @@ import 'package:sentry_flutter/sentry_flutter.dart';
class GetAnalyticsController {
late PangeaController _pangeaController;
final List<AnalyticsCacheEntry> _cache = [];
StreamSubscription<AnalyticsUpdateType>? _analyticsUpdateSubscription;
StreamSubscription<AnalyticsUpdate>? _analyticsUpdateSubscription;
CachedStreamController<List<OneConstructUse>> analyticsStream =
CachedStreamController<List<OneConstructUse>>();
@ -87,8 +87,9 @@ class GetAnalyticsController {
prevXP = null;
}
Future<void> onAnalyticsUpdate(AnalyticsUpdateType type) async {
if (type == AnalyticsUpdateType.server) {
Future<void> onAnalyticsUpdate(AnalyticsUpdate analyticsUpdate) async {
if (analyticsUpdate.isLogout) return;
if (analyticsUpdate.type == AnalyticsUpdateType.server) {
await getConstructs(forceUpdate: true);
}
updateAnalyticsStream();

View file

@ -21,8 +21,8 @@ enum AnalyticsUpdateType { server, local }
/// 2) constructs used by the user, both in sending messages and doing practice activities
class MyAnalyticsController extends BaseController<AnalyticsStream> {
late PangeaController _pangeaController;
CachedStreamController<AnalyticsUpdateType> analyticsUpdateStream =
CachedStreamController<AnalyticsUpdateType>();
CachedStreamController<AnalyticsUpdate> analyticsUpdateStream =
CachedStreamController<AnalyticsUpdate>();
StreamSubscription<AnalyticsStream>? _analyticsStream;
Timer? _updateTimer;
@ -237,7 +237,9 @@ class MyAnalyticsController extends BaseController<AnalyticsStream> {
final int newLevel = _pangeaController.analytics.level;
newLevel > prevLevel
? sendLocalAnalyticsToAnalyticsRoom()
: analyticsUpdateStream.add(AnalyticsUpdateType.local);
: analyticsUpdateStream.add(
AnalyticsUpdate(AnalyticsUpdateType.local),
);
}
/// Clears the local cache of recently sent constructs. Called before updating analytics
@ -281,7 +283,9 @@ class MyAnalyticsController extends BaseController<AnalyticsStream> {
/// for the completion of the previous update and returns. Otherwise, it creates a new [_updateCompleter] and
/// proceeds with the update process. If the update is successful, it clears any messages that were received
/// since the last update and notifies the [analyticsUpdateStream].
Future<void> sendLocalAnalyticsToAnalyticsRoom() async {
Future<void> sendLocalAnalyticsToAnalyticsRoom({
onLogout = false,
}) async {
if (_pangeaController.matrixState.client.userID == null) return;
if (!(_updateCompleter?.isCompleted ?? true)) {
await _updateCompleter!.future;
@ -293,7 +297,12 @@ class MyAnalyticsController extends BaseController<AnalyticsStream> {
clearMessagesSinceUpdate();
lastUpdated = DateTime.now();
analyticsUpdateStream.add(AnalyticsUpdateType.server);
analyticsUpdateStream.add(
AnalyticsUpdate(
AnalyticsUpdateType.server,
isLogout: onLogout,
),
);
} catch (err, s) {
ErrorHandler.logError(
e: err,
@ -340,3 +349,10 @@ class AnalyticsStream {
required this.constructs,
});
}
class AnalyticsUpdate {
final AnalyticsUpdateType type;
final bool isLogout;
AnalyticsUpdate(this.type, {this.isLogout = false});
}

View file

@ -2,7 +2,6 @@ import 'dart:async';
import 'dart:collection';
import 'dart:developer';
import 'package:fluffychat/pangea/constants/local.key.dart';
import 'package:fluffychat/pangea/constants/pangea_event_types.dart';
import 'package:fluffychat/pangea/controllers/pangea_controller.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension/pangea_room_extension.dart';
@ -26,66 +25,60 @@ class PracticeActivityRecordController {
static const int maxStoredEvents = 100;
static final Map<int, _RecordCacheItem> _cache = {};
late final PangeaController _pangeaController;
Timer? _cacheClearTimer;
PracticeActivityRecordController(this._pangeaController) {
_initializeCacheClearing();
PracticeActivityRecordController(this._pangeaController);
int getCompletedActivityCount(String messageID) {
return _completedActivities[messageID] ?? 0;
}
LinkedHashMap<String, int> get completedActivities {
try {
final dynamic locallySaved = _pangeaController.pStoreService.read(
PLocalKey.completedActivities,
);
if (locallySaved == null) return LinkedHashMap<String, int>();
try {
final LinkedHashMap<String, int> cache =
LinkedHashMap<String, int>.from(locallySaved);
return cache;
} catch (err) {
_pangeaController.pStoreService.delete(
PLocalKey.completedActivities,
);
return LinkedHashMap<String, int>();
}
} catch (exception, stackTrace) {
ErrorHandler.logError(
e: PangeaWarningError(
"Failed to get completed activities from cache: $exception",
),
s: stackTrace,
m: 'Failed to get completed activities from cache',
);
return LinkedHashMap<String, int>();
}
}
final LinkedHashMap<String, int> _completedActivities =
LinkedHashMap<String, int>();
// LinkedHashMap<String, int> get _completedActivities {
// try {
// final dynamic locallySaved = _pangeaController.pStoreService.read(
// PLocalKey.completedActivities,
// );
// if (locallySaved == null) return LinkedHashMap<String, int>();
// try {
// final LinkedHashMap<String, int> cache =
// LinkedHashMap<String, int>.from(locallySaved);
// return cache;
// } catch (err) {
// _pangeaController.pStoreService.delete(
// PLocalKey.completedActivities,
// );
// return LinkedHashMap<String, int>();
// }
// } catch (exception, stackTrace) {
// ErrorHandler.logError(
// e: PangeaWarningError(
// "Failed to get completed activities from cache: $exception",
// ),
// s: stackTrace,
// m: 'Failed to get completed activities from cache',
// );
// return LinkedHashMap<String, int>();
// }
// }
Future<void> completeActivity(String messageID) async {
final LinkedHashMap<String, int> currentCache = completedActivities;
final numCompleted = currentCache[messageID] ?? 0;
currentCache[messageID] = numCompleted + 1;
final numCompleted = _completedActivities[messageID] ?? 0;
_completedActivities[messageID] = numCompleted + 1;
// final LinkedHashMap<String, int> currentCache = _completedActivities;
// final numCompleted = currentCache[messageID] ?? 0;
// currentCache[messageID] = numCompleted + 1;
if (currentCache.length > maxStoredEvents) {
currentCache.remove(currentCache.keys.first);
}
// if (currentCache.length > maxStoredEvents) {
// currentCache.remove(currentCache.keys.first);
// }
await _pangeaController.pStoreService.save(
PLocalKey.completedActivities,
currentCache,
);
}
void _initializeCacheClearing() {
const duration = Duration(minutes: 2);
_cacheClearTimer = Timer.periodic(duration, (Timer t) => _clearCache());
}
void _clearCache() {
_cache.clear();
}
void dispose() {
_cacheClearTimer?.cancel();
// await _pangeaController.pStoreService.save(
// PLocalKey.completedActivities,
// currentCache,
// );
debugPrint("completed activities is now: $_completedActivities");
}
/// Sends a practice activity record to the server and returns the corresponding event.

View file

@ -121,19 +121,26 @@ class UserController extends BaseController {
/// Initializes the user's profile by waiting for account data to load, reading in account
/// data to profile, and migrating from the pangea profile if the account data is not present.
Future<void> _initialize() async {
// wait for account data to load
// as long as it's not null, then this we've already migrated the profile
await _pangeaController.matrixState.client.waitForAccountData();
if (profile.userSettings.dateOfBirth != null) {
return;
}
// we used to store the user's profile in the pangea server
// we now store it in the matrix account data
final PangeaProfileResponse? resp = await PUserRepo.fetchPangeaUserInfo(
userID: userId!,
matrixAccessToken: _matrixAccessToken!,
);
// if it's null, we don't have a profile in the pangea server
if (resp?.profile == null) {
return;
}
// if we have a profile in the pangea server, we need to migrate it to the matrix account data
final userSetting = UserSettings.fromJson(resp!.profile.toJson());
final newProfile = Profile(userSettings: userSetting);
await newProfile.saveProfileData(waitForDataInSync: true);

View file

@ -125,3 +125,12 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum {
}
}
}
class ConstructUseTypeUtil {
static ConstructUseTypeEnum fromString(String value) {
return ConstructUseTypeEnum.values.firstWhere(
(e) => e.string == value,
orElse: () => ConstructUseTypeEnum.nan,
);
}
}

View file

@ -99,7 +99,7 @@ extension AnalyticsRoomExtension on Room {
await analyticsRoom.requestParticipants();
}
final List<User> participants = await analyticsRoom.requestParticipants();
final List<User> participants = analyticsRoom.getParticipants();
final List<User> uninvitedTeachers = teachersLocal
.where((teacher) => !participants.contains(teacher))
.toList();
@ -110,12 +110,8 @@ extension AnalyticsRoomExtension on Room {
(teacher) => analyticsRoom.invite(teacher.id).catchError((err, s) {
ErrorHandler.logError(
e: err,
m: "Failed to invite teacher to analytics room",
m: "Failed to invite teacher ${teacher.id} to analytics room ${analyticsRoom.id}",
s: s,
data: {
"teacherId": teacher.id,
"analyticsRoomId": analyticsRoom.id,
},
);
}),
),

View file

@ -540,8 +540,7 @@ class PangeaMessageEvent {
int get numberOfActivitiesCompleted {
return MatrixState.pangeaController.activityRecordController
.completedActivities[eventId] ??
0;
.getCompletedActivityCount(eventId);
}
String? get l2Code =>

View file

@ -1,6 +1,5 @@
import 'dart:developer';
import 'package:collection/collection.dart';
import 'package:fluffychat/pangea/enum/construct_use_type_enum.dart';
import 'package:fluffychat/pangea/utils/error_handler.dart';
import 'package:flutter/foundation.dart';
@ -106,9 +105,7 @@ class OneConstructUse {
debugger(when: kDebugMode && constructType == null);
return OneConstructUse(
useType: ConstructUseTypeEnum.values
.firstWhereOrNull((e) => e.string == json['useType']) ??
ConstructUseTypeEnum.unk,
useType: ConstructUseTypeUtil.fromString(json['useType']),
lemma: json['lemma'],
form: json['form'],
categories: json['categories'] != null

View file

@ -27,12 +27,7 @@ class ConstructWithXP {
? DateTime.parse(json['last_used'] as String)
: null,
condensedConstructUses: (json['uses'] as List<String>).map((e) {
return ConstructUseTypeEnum.values.firstWhereOrNull(
(element) =>
element.string == e ||
element.toString().split('.').last == e,
) ??
ConstructUseTypeEnum.nan;
return ConstructUseTypeUtil.fromString(e);
}).toList(),
);
}

View file

@ -30,6 +30,7 @@ class InlineTooltip extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
// Lightbulb icon on the left
Icon(
@ -39,16 +40,14 @@ class InlineTooltip extends StatelessWidget {
),
const SizedBox(width: 8),
// Text in the middle
Expanded(
child: Center(
child: Text(
instructionsEnum.body(context),
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
height: 1.5,
),
textAlign: TextAlign.left,
Center(
child: Text(
instructionsEnum.body(context),
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
height: 1.5,
),
textAlign: TextAlign.left,
),
),
// Close button on the right

View file

@ -21,7 +21,7 @@ void pLogoutAction(BuildContext context, {bool? isDestructiveAction}) async {
// before wiping out locally cached construct data, save it to the server
await MatrixState.pangeaController.myAnalytics
.sendLocalAnalyticsToAnalyticsRoom();
.sendLocalAnalyticsToAnalyticsRoom(onLogout: true);
await showFutureLoadingDialog(
context: context,

View file

@ -1,6 +1,7 @@
import 'dart:developer';
import 'dart:math';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pages/chat/events/audio_player.dart';
import 'package:fluffychat/pangea/controllers/text_to_speech_controller.dart';
import 'package:fluffychat/pangea/extensions/pangea_event_extension.dart';
@ -8,7 +9,6 @@ import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dar
import 'package:fluffychat/pangea/models/pangea_token_model.dart';
import 'package:fluffychat/pangea/utils/error_handler.dart';
import 'package:fluffychat/pangea/widgets/chat/message_selection_overlay.dart';
import 'package:fluffychat/pangea/widgets/chat/message_toolbar.dart';
import 'package:fluffychat/pangea/widgets/chat/toolbar_content_loading_indicator.dart';
import 'package:fluffychat/pangea/widgets/chat/tts_controller.dart';
import 'package:fluffychat/pangea/widgets/igc/card_error_widget.dart';
@ -21,11 +21,15 @@ class MessageAudioCard extends StatefulWidget {
final PangeaMessageEvent messageEvent;
final MessageOverlayController overlayController;
final PangeaTokenText? selection;
final TtsController tts;
final Function(bool) setIsPlayingAudio;
const MessageAudioCard({
super.key,
required this.messageEvent,
required this.overlayController,
required this.tts,
required this.setIsPlayingAudio,
this.selection,
});
@ -40,8 +44,6 @@ class MessageAudioCardState extends State<MessageAudioCard> {
int? sectionStartMS;
int? sectionEndMS;
TtsController tts = TtsController();
@override
void initState() {
super.initState();
@ -56,7 +58,7 @@ class MessageAudioCardState extends State<MessageAudioCard> {
@override
void didUpdateWidget(covariant oldWidget) {
if (oldWidget.selection != widget.selection) {
if (oldWidget.selection != widget.selection && widget.selection != null) {
debugPrint('selection changed');
setSectionStartAndEndFromSelection();
playSelectionAudio();
@ -65,10 +67,11 @@ class MessageAudioCardState extends State<MessageAudioCard> {
}
Future<void> playSelectionAudio() async {
if (widget.selection == null) return;
final PangeaTokenText selection = widget.selection!;
final tokenText = selection.content;
await tts.speak(tokenText);
await widget.tts.speak(tokenText);
}
void setSectionStartAndEnd(int? start, int? end) => mounted
@ -190,7 +193,6 @@ class MessageAudioCardState extends State<MessageAudioCard> {
children: [
Container(
padding: const EdgeInsets.all(8),
constraints: const BoxConstraints(minHeight: minCardHeight),
alignment: Alignment.center,
child: _isLoading
? const ToolbarContentLoadingIndicator()
@ -204,12 +206,14 @@ class MessageAudioCardState extends State<MessageAudioCard> {
sectionEndMS: sectionEndMS,
color:
Theme.of(context).colorScheme.onPrimaryContainer,
setIsPlayingAudio: widget.setIsPlayingAudio,
),
tts.missingVoiceButton,
widget.tts.missingVoiceButton,
],
)
: const CardErrorWidget(
error: "Null audio file in message_audio_card",
maxWidth: AppConfig.toolbarMinWidth,
),
),
],

View file

@ -17,6 +17,7 @@ import 'package:fluffychat/pangea/widgets/chat/message_toolbar_buttons.dart';
import 'package:fluffychat/pangea/widgets/chat/overlay_footer.dart';
import 'package:fluffychat/pangea/widgets/chat/overlay_header.dart';
import 'package:fluffychat/pangea/widgets/chat/overlay_message.dart';
import 'package:fluffychat/pangea/widgets/chat/tts_controller.dart';
import 'package:fluffychat/widgets/avatar.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/foundation.dart';
@ -61,11 +62,12 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
/// The number of activities that need to be completed before the toolbar is unlocked
/// If we don't have any good activities for them, we'll decrease this number
static const int neededActivities = 3;
int activitiesLeftToComplete = neededActivities;
PangeaMessageEvent get pangeaMessageEvent => widget._pangeaMessageEvent;
final TtsController tts = TtsController();
bool isPlayingAudio = false;
@override
void initState() {
super.initState();
@ -98,6 +100,7 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
).listen((_) => setState(() {}));
setInitialToolbarMode();
tts.setupTTS();
}
/// We need to check if the setState call is safe to call immediately
@ -198,9 +201,10 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
PangeaToken token,
) {
if ([
MessageMode.practiceActivity,
// MessageMode.textToSpeech
].contains(toolbarMode)) {
MessageMode.practiceActivity,
// MessageMode.textToSpeech
].contains(toolbarMode) ||
isPlayingAudio) {
return;
}
@ -271,6 +275,12 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
double get reactionsHeight => hasReactions ? 28 : 0;
double get belowMessageHeight => toolbarButtonsHeight + reactionsHeight;
void setIsPlayingAudio(bool isPlaying) {
if (mounted) {
setState(() => isPlayingAudio = isPlaying);
}
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
@ -359,6 +369,7 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
void dispose() {
_animationController.dispose();
_reactionSubscription?.cancel();
tts.dispose();
super.dispose();
}
@ -443,9 +454,7 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
}
final overlayMessage = Container(
constraints: BoxConstraints(
maxWidth: maxWidth,
),
constraints: BoxConstraints(maxWidth: maxWidth),
child: Material(
type: MaterialType.transparency,
child: Column(
@ -457,6 +466,7 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
MessageToolbar(
pangeaMessageEvent: widget._pangeaMessageEvent,
overLayController: this,
tts: tts,
),
SizedBox(
height: adjustedMessageHeight,

View file

@ -1,5 +1,6 @@
import 'dart:developer';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pangea/enum/instructions_enum.dart';
import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/pangea/models/speech_to_text_models.dart';
@ -148,9 +149,12 @@ class MessageSpeechToTextCardState extends State<MessageSpeechToTextCard> {
return const ToolbarContentLoadingIndicator();
}
//done fetchig but not results means some kind of error
// done fetchig but not results means some kind of error
if (speechToTextResponse == null) {
return CardErrorWidget(error: error);
return CardErrorWidget(
error: error,
maxWidth: AppConfig.toolbarMinWidth,
);
}
//TODO: find better icons

View file

@ -10,6 +10,7 @@ import 'package:fluffychat/pangea/widgets/chat/message_selection_overlay.dart';
import 'package:fluffychat/pangea/widgets/chat/message_speech_to_text_card.dart';
import 'package:fluffychat/pangea/widgets/chat/message_translation_card.dart';
import 'package:fluffychat/pangea/widgets/chat/message_unsubscribed_card.dart';
import 'package:fluffychat/pangea/widgets/chat/tts_controller.dart';
import 'package:fluffychat/pangea/widgets/igc/word_data_card.dart';
import 'package:fluffychat/pangea/widgets/practice_activity/practice_activity_card.dart';
import 'package:fluffychat/pangea/widgets/select_to_define.dart';
@ -22,11 +23,13 @@ const double minCardHeight = 70;
class MessageToolbar extends StatelessWidget {
final PangeaMessageEvent pangeaMessageEvent;
final MessageOverlayController overLayController;
final TtsController tts;
const MessageToolbar({
super.key,
required this.pangeaMessageEvent,
required this.overLayController,
required this.tts,
});
Widget get toolbarContent {
@ -50,6 +53,8 @@ class MessageToolbar extends StatelessWidget {
messageEvent: pangeaMessageEvent,
overlayController: overLayController,
selection: overLayController.selectedSpan,
tts: tts,
setIsPlayingAudio: overLayController.setIsPlayingAudio,
);
case MessageMode.speechToText:
return MessageSpeechToTextCard(
@ -87,6 +92,7 @@ class MessageToolbar extends StatelessWidget {
return PracticeActivityCard(
pangeaMessageEvent: pangeaMessageEvent,
overlayController: overLayController,
tts: tts,
);
default:
debugger(when: kDebugMode);
@ -114,6 +120,9 @@ class MessageToolbar extends StatelessWidget {
),
constraints: const BoxConstraints(
maxHeight: AppConfig.toolbarMaxHeight,
minWidth: AppConfig.toolbarMinWidth,
minHeight: AppConfig.toolbarMinHeight,
// maxWidth is set by MessageSelectionOverlay
),
child: SingleChildScrollView(
child: AnimatedSize(

View file

@ -1,3 +1,4 @@
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pangea/enum/instructions_enum.dart';
import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/pangea/models/pangea_token_model.dart';
@ -130,46 +131,51 @@ class MessageTranslationCardState extends State<MessageTranslationCard> {
if (!_fetchingTranslation &&
repEvent == null &&
selectionTranslation == null) {
return const CardErrorWidget(error: "No translation found");
return const CardErrorWidget(
error: "No translation found",
maxWidth: AppConfig.toolbarMinWidth,
);
}
final loadingTranslation =
(widget.selection != null && selectionTranslation == null) ||
(widget.selection == null && repEvent == null);
if (_fetchingTranslation || loadingTranslation) {
return const ToolbarContentLoadingIndicator();
}
return Padding(
padding: const EdgeInsets.all(8),
padding: const EdgeInsets.fromLTRB(16, 20, 16, 16),
child: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
_fetchingTranslation
? const ToolbarContentLoadingIndicator()
: Flexible(
child: Column(
children: [
widget.selection != null
? selectionTranslation != null
? Text(
selectionTranslation!,
style: BotStyle.text(context),
)
: const ToolbarContentLoadingIndicator()
: repEvent != null
? Text(
repEvent!.text,
style: BotStyle.text(context),
)
: const ToolbarContentLoadingIndicator(),
if (notGoingToTranslate && widget.selection == null)
InlineTooltip(
instructionsEnum: InstructionsEnum.l1Translation,
onClose: () => setState(() {}),
),
if (widget.selection != null)
InlineTooltip(
instructionsEnum:
InstructionsEnum.clickAgainToDeselect,
onClose: () => setState(() {}),
),
],
),
Flexible(
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
widget.selection != null
? selectionTranslation!
: repEvent!.text,
style: BotStyle.text(context),
textAlign: TextAlign.center,
),
if (notGoingToTranslate && widget.selection == null)
InlineTooltip(
instructionsEnum: InstructionsEnum.l1Translation,
onClose: () => setState(() {}),
),
if (widget.selection != null)
InlineTooltip(
instructionsEnum: InstructionsEnum.clickAgainToDeselect,
onClose: () => setState(() {}),
),
],
),
),
],
),
);

View file

@ -18,8 +18,8 @@ class MessageUnsubscribedCard extends StatelessWidget {
final bool inTrialWindow =
MatrixState.pangeaController.userController.inTrialWindow;
return Container(
padding: const EdgeInsets.all(8),
return Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
Text(

View file

@ -1,4 +1,4 @@
import 'package:fluffychat/pangea/widgets/chat/message_toolbar.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:flutter/material.dart';
class ToolbarContentLoadingIndicator extends StatelessWidget {
@ -8,10 +8,9 @@ class ToolbarContentLoadingIndicator extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(8),
constraints: const BoxConstraints(minHeight: minCardHeight),
alignment: Alignment.center,
return SizedBox(
width: AppConfig.toolbarMinWidth,
height: AppConfig.toolbarMinHeight,
child: Center(
child: SizedBox(
height: 14,

View file

@ -23,7 +23,8 @@ class TtsController {
}
onError(dynamic message) => ErrorHandler.logError(
m: 'TTS error',
e: message,
m: (message.toString().isNotEmpty) ? message.toString() : 'TTS error',
data: {
'message': message,
},
@ -82,13 +83,11 @@ class TtsController {
debugger(when: kDebugMode);
ErrorHandler.logError(e: e, s: s);
}
await tts.stop();
}
Future<void> speak(String text) async {
try {
stop();
targetLanguage ??=
MatrixState.pangeaController.languageController.userL2?.langCode;

View file

@ -32,6 +32,8 @@ class OverlayContainer extends StatelessWidget {
constraints: BoxConstraints(
maxWidth: maxWidth,
maxHeight: maxHeight,
minHeight: 100,
minWidth: 100,
),
//PTODO - position card above input/message
// margin: const EdgeInsets.all(10),

View file

@ -9,21 +9,26 @@ class CardErrorWidget extends StatelessWidget {
final Object? error;
final Choreographer? choreographer;
final int? offset;
final double? maxWidth;
const CardErrorWidget({
super.key,
this.error,
this.choreographer,
this.offset,
this.maxWidth,
});
@override
Widget build(BuildContext context) {
final ErrorCopy errorCopy = ErrorCopy(context, error);
return Padding(
padding: const EdgeInsets.all(8),
return ConstrainedBox(
constraints: maxWidth != null
? BoxConstraints(maxWidth: maxWidth!)
: const BoxConstraints(),
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
CardHeader(
text: errorCopy.title,
@ -32,11 +37,13 @@ class CardErrorWidget extends StatelessWidget {
cursorOffset: offset,
),
),
const SizedBox(height: 10.0),
Center(
const SizedBox(height: 12.0),
Padding(
padding: const EdgeInsets.all(12),
child: Text(
errorCopy.body,
style: BotStyle.text(context),
textAlign: TextAlign.center,
),
),
],

View file

@ -1,8 +1,8 @@
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pangea/utils/bot_style.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/material.dart';
import '../../../widgets/matrix.dart';
import '../common/bot_face_svg.dart';
class CardHeader extends StatelessWidget {
@ -22,33 +22,31 @@ class CardHeader extends StatelessWidget {
return Padding(
padding: const EdgeInsets.only(bottom: 5.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Padding(
padding: const EdgeInsets.only(top: 3.0),
child: BotFace(
width: 50.0,
expression: botExpression,
BotFace(
width: 50.0,
expression: botExpression,
),
const SizedBox(width: 12.0),
Flexible(
child: Text(
text,
style: BotStyle.text(context),
softWrap: true,
),
),
const SizedBox(width: 5.0),
Text(
text,
style: BotStyle.text(context),
textAlign: TextAlign.left,
),
const SizedBox(width: 5.0),
CircleAvatar(
backgroundColor: AppConfig.primaryColor.withOpacity(0.1),
child: IconButton(
icon: const Icon(Icons.close_outlined),
onPressed: () {
if (onClose != null) onClose!();
MatrixState.pAnyState.closeOverlay();
},
color: Theme.of(context).brightness == Brightness.dark
? AppConfig.primaryColorLight
: AppConfig.primaryColor,
),
IconButton(
icon: const Icon(Icons.close_outlined),
onPressed: () {
if (onClose != null) onClose!();
MatrixState.pAnyState.closeOverlay();
},
color: Theme.of(context).brightness == Brightness.dark
? AppConfig.primaryColorLight
: AppConfig.primaryColor,
),
],
),

View file

@ -1,5 +1,6 @@
import 'dart:developer';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pangea/constants/language_constants.dart';
import 'package:fluffychat/pangea/controllers/contextual_definition_controller.dart';
import 'package:fluffychat/pangea/controllers/pangea_controller.dart';
@ -7,8 +8,7 @@ import 'package:fluffychat/pangea/models/language_model.dart';
import 'package:fluffychat/pangea/utils/bot_style.dart';
import 'package:fluffychat/pangea/utils/error_handler.dart';
import 'package:fluffychat/pangea/utils/firebase_analytics.dart';
import 'package:fluffychat/pangea/widgets/chat/message_toolbar.dart';
import 'package:fluffychat/pangea/widgets/common/p_circular_loader.dart';
import 'package:fluffychat/pangea/widgets/chat/toolbar_content_loading_indicator.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
@ -166,71 +166,68 @@ class WordDataCardView extends StatelessWidget {
@override
Widget build(BuildContext context) {
if (controller.wordNetError != null) {
return CardErrorWidget(error: controller.wordNetError);
return CardErrorWidget(
error: controller.wordNetError,
maxWidth: AppConfig.toolbarMinWidth,
);
}
if (controller.activeL1 == null || controller.activeL2 == null) {
ErrorHandler.logError(m: "should not be here");
return CardErrorWidget(error: controller.noLanguages);
return CardErrorWidget(
error: controller.noLanguages,
maxWidth: AppConfig.toolbarMinWidth,
);
}
final ScrollController scrollController = ScrollController();
return Container(
padding: const EdgeInsets.all(8),
constraints: const BoxConstraints(minHeight: minCardHeight),
alignment: Alignment.center,
child: Scrollbar(
thumbVisibility: true,
controller: scrollController,
child: SingleChildScrollView(
controller: scrollController,
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (controller.widget.choiceFeedback != null)
Text(
controller.widget.choiceFeedback!,
style: BotStyle.text(context),
),
const SizedBox(height: 5.0),
if (controller.wordData != null &&
controller.wordNetError == null &&
controller.activeL1 != null &&
controller.activeL2 != null)
WordNetInfo(
wordData: controller.wordData!,
activeL1: controller.activeL1!,
activeL2: controller.activeL2!,
),
if (controller.isLoadingWordNet) const PCircular(),
const SizedBox(height: 5.0),
// if (controller.widget.hasInfo &&
// !controller.isLoadingContextualDefinition &&
// controller.contextualDefinitionRes == null)
// Material(
// type: MaterialType.transparency,
// child: ListTile(
// leading: const BotFace(
// width: 40, expression: BotExpression.surprised),
// title: Text(L10n.of(context)!.askPangeaBot),
// onTap: controller.handleGetDefinitionButtonPress,
// ),
// ),
if (controller.isLoadingContextualDefinition) const PCircular(),
if (controller.contextualDefinitionRes != null)
Text(
controller.contextualDefinitionRes!.text,
style: BotStyle.text(context),
),
if (controller.definitionError != null)
Text(
L10n.of(context)!.sorryNoResults,
style: BotStyle.text(context),
),
],
),
),
return Padding(
padding: const EdgeInsets.fromLTRB(16, 20, 16, 16),
child: Column(
children: [
if (controller.widget.choiceFeedback != null)
Text(
controller.widget.choiceFeedback!,
style: BotStyle.text(context),
),
const SizedBox(height: 5.0),
if (controller.wordData != null &&
controller.wordNetError == null &&
controller.activeL1 != null &&
controller.activeL2 != null)
WordNetInfo(
wordData: controller.wordData!,
activeL1: controller.activeL1!,
activeL2: controller.activeL2!,
),
if (controller.isLoadingWordNet)
const ToolbarContentLoadingIndicator(),
const SizedBox(height: 5.0),
// if (controller.widget.hasInfo &&
// !controller.isLoadingContextualDefinition &&
// controller.contextualDefinitionRes == null)
// Material(
// type: MaterialType.transparency,
// child: ListTile(
// leading: const BotFace(
// width: 40, expression: BotExpression.surprised),
// title: Text(L10n.of(context)!.askPangeaBot),
// onTap: controller.handleGetDefinitionButtonPress,
// ),
// ),
if (controller.isLoadingContextualDefinition)
const ToolbarContentLoadingIndicator(),
if (controller.contextualDefinitionRes != null)
Text(
controller.contextualDefinitionRes!.text,
style: BotStyle.text(context),
textAlign: TextAlign.center,
),
if (controller.definitionError != null)
Text(
L10n.of(context)!.sorryNoResults,
style: BotStyle.text(context),
textAlign: TextAlign.center,
),
],
),
);
}
@ -251,12 +248,14 @@ class WordNetInfo extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SensesForLanguage(
wordData: wordData,
languageType: LanguageType.target,
language: activeL2,
),
const SizedBox(height: 10),
SensesForLanguage(
wordData: wordData,
languageType: LanguageType.base,
@ -273,52 +272,6 @@ enum LanguageType {
}
class SensesForLanguage extends StatelessWidget {
const SensesForLanguage({
super.key,
required this.wordData,
required this.languageType,
required this.language,
});
final LanguageModel language;
final LanguageType languageType;
final WordData wordData;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 5),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(7, 0, 0, 0),
child: LanguageFlag(
language: language,
),
),
Expanded(
child: PartOfSpeechBlock(
wordData: wordData,
languageType: languageType,
),
),
],
),
);
}
}
class PartOfSpeechBlock extends StatelessWidget {
final WordData wordData;
final LanguageType languageType;
const PartOfSpeechBlock({
super.key,
required this.wordData,
required this.languageType,
});
String get exampleSentence => languageType == LanguageType.target
? wordData.targetExampleSentence
: wordData.baseExampleSentence;
@ -336,70 +289,76 @@ class PartOfSpeechBlock extends StatelessWidget {
return "$word (${wordData.formattedPartOfSpeech(languageType)})";
}
const SensesForLanguage({
super.key,
required this.wordData,
required this.languageType,
required this.language,
});
final LanguageModel language;
final LanguageType languageType;
final WordData wordData;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(10, 0, 10, 0),
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
children: [
Align(
alignment: Alignment.centerLeft,
child: Text(
formattedTitle(context),
style: BotStyle.text(context, italics: true, bold: false),
),
),
const SizedBox(height: 10),
Padding(
padding: const EdgeInsets.only(left: 14.0, bottom: 10.0),
child: Align(
alignment: Alignment.centerLeft,
child: Column(
children: [
if (definition.isNotEmpty)
RichText(
text: TextSpan(
style: BotStyle.text(
context,
italics: false,
bold: false,
),
children: <TextSpan>[
TextSpan(
text: "${L10n.of(context)!.definition}: ",
style: const TextStyle(fontWeight: FontWeight.bold),
),
TextSpan(text: definition),
],
),
),
const SizedBox(height: 10),
if (exampleSentence.isNotEmpty)
RichText(
text: TextSpan(
style: BotStyle.text(
context,
italics: false,
bold: false,
),
children: <TextSpan>[
TextSpan(
text: "${L10n.of(context)!.exampleSentence}: ",
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
TextSpan(text: exampleSentence),
],
),
),
],
return Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
LanguageFlag(language: language),
const SizedBox(width: 10),
Flexible(
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
formattedTitle(context),
style: BotStyle.text(context, italics: true, bold: false),
),
),
const SizedBox(height: 4),
if (definition.isNotEmpty)
RichText(
text: TextSpan(
style: BotStyle.text(
context,
italics: false,
bold: false,
),
children: <TextSpan>[
TextSpan(
text: "${L10n.of(context)!.definition}: ",
style: const TextStyle(fontWeight: FontWeight.bold),
),
TextSpan(text: definition),
],
),
),
const SizedBox(height: 4),
if (exampleSentence.isNotEmpty)
RichText(
text: TextSpan(
style: BotStyle.text(
context,
italics: false,
bold: false,
),
children: <TextSpan>[
TextSpan(
text: "${L10n.of(context)!.exampleSentence}: ",
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
TextSpan(text: exampleSentence),
],
),
),
],
),
],
),
),
],
);
}
}

View file

@ -6,6 +6,7 @@ import 'package:fluffychat/pangea/controllers/my_analytics_controller.dart';
import 'package:fluffychat/pangea/enum/activity_type_enum.dart';
import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_model.dart';
import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_record_model.dart';
import 'package:fluffychat/pangea/widgets/chat/tts_controller.dart';
import 'package:fluffychat/pangea/widgets/practice_activity/practice_activity_card.dart';
import 'package:fluffychat/pangea/widgets/practice_activity/word_audio_button.dart';
import 'package:fluffychat/widgets/matrix.dart';
@ -16,11 +17,13 @@ import 'package:flutter/material.dart';
class MultipleChoiceActivity extends StatefulWidget {
final PracticeActivityCardState practiceCardController;
final PracticeActivityModel currentActivity;
final TtsController tts;
const MultipleChoiceActivity({
super.key,
required this.practiceCardController,
required this.currentActivity,
required this.tts,
});
@override
@ -112,7 +115,10 @@ class MultipleChoiceActivityState extends State<MultipleChoiceActivity> {
// #freeze-activity
if (practiceActivity.activityType ==
ActivityTypeEnum.wordFocusListening)
WordAudioButton(text: practiceActivity.content.answer),
WordAudioButton(
text: practiceActivity.content.answer,
ttsController: widget.tts,
),
ChoicesArray(
isLoading: false,
uniqueKeyForLayerLink: (index) => "multiple_choice_$index",

View file

@ -1,3 +1,4 @@
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pangea/utils/bot_style.dart';
import 'package:flutter/material.dart';
@ -71,18 +72,21 @@ class GamifiedTextWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(8),
child: Column(
children: [
const StarAnimationWidget(),
const SizedBox(height: 10),
Text(
userMessage,
style: BotStyle.text(context),
textAlign: TextAlign.center,
),
],
return SizedBox(
width: AppConfig.toolbarMinWidth,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 20, 16, 16),
child: Column(
children: [
const StarAnimationWidget(),
const SizedBox(height: 10),
Text(
userMessage,
style: BotStyle.text(context),
textAlign: TextAlign.center,
),
],
),
),
);
}

View file

@ -9,10 +9,11 @@ import 'package:fluffychat/pangea/models/analytics/constructs_model.dart';
import 'package:fluffychat/pangea/models/practice_activities.dart/message_activity_request.dart';
import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_model.dart';
import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_record_model.dart';
import 'package:fluffychat/pangea/utils/bot_style.dart';
import 'package:fluffychat/pangea/utils/error_handler.dart';
import 'package:fluffychat/pangea/widgets/animations/gain_points.dart';
import 'package:fluffychat/pangea/widgets/chat/message_selection_overlay.dart';
import 'package:fluffychat/pangea/widgets/chat/toolbar_content_loading_indicator.dart';
import 'package:fluffychat/pangea/widgets/chat/tts_controller.dart';
import 'package:fluffychat/pangea/widgets/content_issue_button.dart';
import 'package:fluffychat/pangea/widgets/practice_activity/multiple_choice_activity.dart';
import 'package:fluffychat/pangea/widgets/practice_activity/no_more_practice_card.dart';
@ -28,11 +29,13 @@ import 'package:flutter_gen/gen_l10n/l10n.dart';
class PracticeActivityCard extends StatefulWidget {
final PangeaMessageEvent pangeaMessageEvent;
final MessageOverlayController overlayController;
final TtsController tts;
const PracticeActivityCard({
super.key,
required this.pangeaMessageEvent,
required this.overlayController,
required this.tts,
});
@override
@ -286,16 +289,15 @@ class PracticeActivityCardState extends State<PracticeActivityCard> {
/// If there is no current activity, the widget returns a sizedbox with a height of 80.
/// If the activity type is multiple choice, the widget returns a MultipleChoiceActivity.
/// If the activity type is unknown, the widget logs an error and returns a text widget with an error message.
Widget get activityWidget {
if (currentActivity == null) {
// return sizedbox with height of 80
return const SizedBox(height: 80);
}
switch (currentActivity!.activityType) {
Widget? get activityWidget {
switch (currentActivity?.activityType) {
case null:
return null;
case ActivityTypeEnum.multipleChoice:
return MultipleChoiceActivity(
practiceCardController: this,
currentActivity: currentActivity!,
tts: widget.tts,
);
case ActivityTypeEnum.wordFocusListening:
// return WordFocusListeningActivity(
@ -303,19 +305,20 @@ class PracticeActivityCardState extends State<PracticeActivityCard> {
return MultipleChoiceActivity(
practiceCardController: this,
currentActivity: currentActivity!,
tts: widget.tts,
);
default:
ErrorHandler.logError(
e: Exception('Unknown activity type'),
m: 'Unknown activity type',
data: {
'activityType': currentActivity!.activityType,
},
);
return Text(
L10n.of(context)!.oopsSomethingWentWrong,
style: BotStyle.text(context),
);
// default:
// ErrorHandler.logError(
// e: Exception('Unknown activity type'),
// m: 'Unknown activity type',
// data: {
// 'activityType': currentActivity!.activityType,
// },
// );
// return Text(
// L10n.of(context)!.oopsSomethingWentWrong,
// style: BotStyle.text(context),
// );
}
}
@ -334,20 +337,15 @@ class PracticeActivityCardState extends State<PracticeActivityCard> {
const Positioned(
child: PointsGainedAnimation(),
),
Padding(
padding: const EdgeInsets.fromLTRB(8, 20, 8, 8),
child: activityWidget,
),
if (activityWidget != null)
Padding(
padding: const EdgeInsets.fromLTRB(16, 20, 16, 16),
child: activityWidget,
),
// Conditionally show the darkening and progress indicator based on the loading state
if (!savoringTheJoy && fetchingActivity) ...[
// Semi-transparent overlay
Container(
color: Colors.black.withOpacity(0.5), // Darkening effect
),
// Circular progress indicator in the center
const Center(
child: CircularProgressIndicator(),
),
const ToolbarContentLoadingIndicator(),
],
// Flag button in the top right corner
Positioned(

View file

@ -4,10 +4,12 @@ import 'package:flutter_gen/gen_l10n/l10n.dart';
class WordAudioButton extends StatefulWidget {
final String text;
final TtsController ttsController;
const WordAudioButton({
super.key,
required this.text,
required this.ttsController,
});
@override
@ -17,22 +19,6 @@ class WordAudioButton extends StatefulWidget {
class WordAudioButtonState extends State<WordAudioButton> {
bool _isPlaying = false;
TtsController ttsController = TtsController();
@override
void initState() {
// TODO: implement initState
debugPrint('initState WordAudioButton');
super.initState();
ttsController.setupTTS().then((value) => setState(() {}));
}
@override
void dispose() {
ttsController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
debugPrint('build WordAudioButton');
@ -54,7 +40,7 @@ class WordAudioButtonState extends State<WordAudioButton> {
_isPlaying ? L10n.of(context)!.stop : L10n.of(context)!.playAudio,
onPressed: () async {
if (_isPlaying) {
await ttsController.tts.stop();
await widget.ttsController.tts.stop();
if (mounted) {
setState(() => _isPlaying = false);
}
@ -62,7 +48,7 @@ class WordAudioButtonState extends State<WordAudioButton> {
if (mounted) {
setState(() => _isPlaying = true);
}
await ttsController.speak(widget.text);
await widget.ttsController.speak(widget.text);
if (mounted) {
setState(() => _isPlaying = false);
}
@ -70,7 +56,7 @@ class WordAudioButtonState extends State<WordAudioButton> {
}, // Disable button if language isn't supported
),
// #freeze-activity
ttsController.missingVoiceButton,
widget.ttsController.missingVoiceButton,
],
);
}

View file

@ -10,18 +10,11 @@ class SelectToDefine extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
L10n.of(context)!.selectToDefine,
style: BotStyle.text(context),
textAlign: TextAlign.center,
),
),
],
padding: const EdgeInsets.fromLTRB(16, 20, 16, 16),
child: Text(
L10n.of(context)!.selectToDefine,
style: BotStyle.text(context),
textAlign: TextAlign.center,
),
);
}

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: 1.21.5+3545
version: 1.22.6+3556
environment:
sdk: ">=3.0.0 <4.0.0"