diff --git a/.gitignore b/.gitignore index f82fba905..4713b0ba8 100644 --- a/.gitignore +++ b/.gitignore @@ -15,7 +15,7 @@ prime *.secrets # Android keys file keys.json -.env +*.env !/public/.env *.env.local_choreo assets/.env.local_choreo diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 038340929..65f900d84 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -5346,5 +5346,6 @@ "courseDescription": "Courses consist of 3-8 modules each with activities to encourage practicing words in different contexts", "emailVerificationFailed": "Email verification failed. Please try again.", "unlockLearningTools": "Unlock learning tools", + "unlockPracticeActivities": "Unlock practice activities", "managementSnackbarMessage": "We launched subscription management in a new tab. If you didn't see the new tab, please check your popup blocker." } diff --git a/lib/pangea/analytics_practice/analytics_practice_view.dart b/lib/pangea/analytics_practice/analytics_practice_view.dart index 56b3eeca4..3b96d15a6 100644 --- a/lib/pangea/analytics_practice/analytics_practice_view.dart +++ b/lib/pangea/analytics_practice/analytics_practice_view.dart @@ -14,7 +14,9 @@ import 'package:fluffychat/pangea/analytics_practice/choice_cards/grammar_choice import 'package:fluffychat/pangea/analytics_practice/choice_cards/meaning_choice_card.dart'; import 'package:fluffychat/pangea/analytics_practice/completed_activity_session_view.dart'; import 'package:fluffychat/pangea/analytics_practice/practice_timer_widget.dart'; +import 'package:fluffychat/pangea/analytics_practice/unsubscribed_practice_page.dart'; import 'package:fluffychat/pangea/analytics_summary/animated_progress_bar.dart'; +import 'package:fluffychat/pangea/common/network/requests.dart'; import 'package:fluffychat/pangea/common/utils/async_state.dart'; import 'package:fluffychat/pangea/common/widgets/error_indicator.dart'; import 'package:fluffychat/pangea/common/widgets/pressable_button.dart'; @@ -86,7 +88,11 @@ class AnalyticsPracticeView extends StatelessWidget { builder: (context, state, _) { return switch (state) { AsyncError(:final error) => - ErrorIndicator(message: error.toLocalizedString(context)), + error is UnsubscribedException + ? const UnsubscribedPracticePage() + : ErrorIndicator( + message: error.toLocalizedString(context), + ), AsyncLoaded(:final value) => value.isComplete ? CompletedActivitySessionView(state.value, controller) diff --git a/lib/pangea/analytics_practice/unsubscribed_practice_page.dart b/lib/pangea/analytics_practice/unsubscribed_practice_page.dart new file mode 100644 index 000000000..64a4080ce --- /dev/null +++ b/lib/pangea/analytics_practice/unsubscribed_practice_page.dart @@ -0,0 +1,175 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +import 'package:fluffychat/config/themes.dart'; +import 'package:fluffychat/l10n/l10n.dart'; +import 'package:fluffychat/pangea/common/widgets/pressable_button.dart'; +import 'package:fluffychat/pangea/common/widgets/shimmer_box.dart'; +import 'package:fluffychat/widgets/matrix.dart'; + +class _DecorativeStar extends StatelessWidget { + final double size; + final double rotation; + + const _DecorativeStar({required this.size, this.rotation = 0}); + + @override + Widget build(BuildContext context) { + return Transform.rotate( + angle: rotation, + child: Opacity( + opacity: .25, + child: Text('⭐', style: TextStyle(fontSize: size)), + ), + ); + } +} + +class UnsubscribedPracticePage extends StatelessWidget { + const UnsubscribedPracticePage({super.key}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isDarkMode = theme.brightness == Brightness.dark; + final placeholderColor = isDarkMode + ? Colors.white.withAlpha(50) + : Colors.black.withAlpha(50); + final primaryColor = theme.colorScheme.primary; + final exampleMessageColor = Color.alphaBlend( + ThemeData.dark().colorScheme.primary, + Colors.white, + ).withAlpha(50); + final isColumnMode = FluffyThemes.isColumnMode(context); + + return Column( + children: [ + Expanded( + child: Stack( + children: [ + Positioned.fill( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: Column( + children: [ + const SizedBox(height: 16.0), + // Title + ShimmerBox( + baseColor: placeholderColor, + highlightColor: primaryColor, + width: 250, + height: 30, + ), + const SizedBox(height: 8.0), + // Phonetic transcription + ShimmerBox( + baseColor: placeholderColor, + highlightColor: primaryColor, + width: 150, + height: 20, + ), + const SizedBox(height: 24.0), + // Center content box (example message) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: ShimmerBox( + baseColor: exampleMessageColor, + highlightColor: primaryColor, + width: double.infinity, + height: 80.0, + borderRadius: BorderRadius.circular(24), + ), + ), + const SizedBox(height: 24.0), + // Choice cards + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Column( + spacing: 8.0, + children: List.generate( + 4, + (index) => ShimmerBox( + baseColor: placeholderColor, + highlightColor: primaryColor, + width: double.infinity, + height: 60.0, + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ), + ], + ), + ), + ), + Positioned( + top: 20, + left: 20, + child: _DecorativeStar( + size: isColumnMode ? 80 : 35, + rotation: -math.pi / 8, + ), + ), + Positioned( + top: 30, + right: 30, + child: _DecorativeStar( + size: isColumnMode ? 90 : 40, + rotation: math.pi / 6, + ), + ), + Positioned( + top: 440, + left: -5, + child: _DecorativeStar( + size: isColumnMode ? 70 : 35, + rotation: math.pi / 4, + ), + ), + Positioned( + top: 450, + right: -5, + child: _DecorativeStar( + size: isColumnMode ? 75 : 35, + rotation: -math.pi / 5, + ), + ), + Center(child: Icon(Icons.lock, size: 80, color: primaryColor)), + ], + ), + ), + Container( + alignment: Alignment.bottomCenter, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: PressableButton( + borderRadius: BorderRadius.circular(36), + color: primaryColor, + onPressed: () { + MatrixState.pangeaController.subscriptionController.showPaywall( + context, + ); + }, + builder: (context, depressed, shadowColor) => Container( + padding: const EdgeInsets.all(16.0), + decoration: BoxDecoration( + color: depressed ? shadowColor : primaryColor, + borderRadius: BorderRadius.circular(36), + ), + child: Text( + L10n.of(context).unlockPracticeActivities, + style: TextStyle( + fontSize: 18.0, + fontWeight: FontWeight.w600, + color: isDarkMode ? Colors.black : Colors.white, + ), + ), + ), + ), + ), + ), + ], + ); + } +} diff --git a/lib/pangea/common/utils/async_state.dart b/lib/pangea/common/utils/async_state.dart index 725c9b06b..2389b1c88 100644 --- a/lib/pangea/common/utils/async_state.dart +++ b/lib/pangea/common/utils/async_state.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:fluffychat/pangea/common/network/requests.dart'; import 'package:fluffychat/pangea/common/utils/error_handler.dart'; /// A generic sealed class that represents the state of an asynchronous operation. @@ -72,8 +73,6 @@ abstract class AsyncLoader { T? get value => isLoaded ? (state.value as AsyncLoaded).value : null; - Completer completer = Completer(); - void dispose() { _disposed = true; state.dispose(); @@ -93,14 +92,12 @@ abstract class AsyncLoader { final result = await fetch(); if (_disposed) return; state.value = AsyncState.loaded(result); - completer.complete(result); } catch (e, s) { - completer.completeError(e); if (!_disposed) { state.value = AsyncState.error(e); } - if (e is! HttpException) { + if (e is! HttpException && e is! UnsubscribedException) { ErrorHandler.logError(e: e, s: s, data: {}); } } @@ -109,6 +106,5 @@ abstract class AsyncLoader { void reset() { if (_disposed) return; state.value = AsyncState.idle(); - completer = Completer(); } } diff --git a/lib/pangea/common/widgets/shimmer_box.dart b/lib/pangea/common/widgets/shimmer_box.dart new file mode 100644 index 000000000..eceebad03 --- /dev/null +++ b/lib/pangea/common/widgets/shimmer_box.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; + +import 'package:shimmer/shimmer.dart'; + +class ShimmerBox extends StatelessWidget { + final Color baseColor; + final Color highlightColor; + final double width; + final double height; + final BorderRadius? borderRadius; + + const ShimmerBox({ + super.key, + required this.baseColor, + required this.highlightColor, + required this.width, + required this.height, + this.borderRadius, + }); + + @override + Widget build(BuildContext context) { + return Shimmer.fromColors( + loop: 1, + baseColor: baseColor, + highlightColor: highlightColor, + child: Container( + width: width, + height: height, + decoration: BoxDecoration( + color: baseColor, + borderRadius: borderRadius ?? BorderRadius.circular(8), + ), + ), + ); + } +} diff --git a/lib/pangea/toolbar/word_card/message_unsubscribed_card.dart b/lib/pangea/toolbar/word_card/message_unsubscribed_card.dart index 8d6b60b52..6ae3afc16 100644 --- a/lib/pangea/toolbar/word_card/message_unsubscribed_card.dart +++ b/lib/pangea/toolbar/word_card/message_unsubscribed_card.dart @@ -1,44 +1,12 @@ import 'package:flutter/material.dart'; -import 'package:shimmer/shimmer.dart'; - import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/l10n/l10n.dart'; import 'package:fluffychat/pangea/common/widgets/pressable_button.dart'; +import 'package:fluffychat/pangea/common/widgets/shimmer_box.dart'; import 'package:fluffychat/pangea/events/models/pangea_token_text_model.dart'; import 'package:fluffychat/widgets/matrix.dart'; -class _ShimmerBox extends StatelessWidget { - final Color baseColor; - final Color highlightColor; - final double width; - final double height; - - const _ShimmerBox({ - required this.baseColor, - required this.highlightColor, - required this.width, - required this.height, - }); - - @override - Widget build(BuildContext context) { - return Shimmer.fromColors( - loop: 1, - baseColor: baseColor, - highlightColor: highlightColor, - child: Container( - width: width, - height: height, - decoration: BoxDecoration( - color: baseColor, - borderRadius: BorderRadius.circular(8), - ), - ), - ); - } -} - class MessageUnsubscribedCard extends StatelessWidget { final PangeaTokenText token; final VoidCallback? onClose; @@ -66,7 +34,7 @@ class MessageUnsubscribedCard extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - _ShimmerBox( + ShimmerBox( baseColor: placeholderColor, highlightColor: primaryColor, width: 200, @@ -79,7 +47,7 @@ class MessageUnsubscribedCard extends StatelessWidget { 4, (index) => Padding( padding: EdgeInsets.only(left: index == 0 ? 0 : 8), - child: _ShimmerBox( + child: ShimmerBox( baseColor: placeholderColor, highlightColor: primaryColor, width: 65, @@ -89,7 +57,7 @@ class MessageUnsubscribedCard extends StatelessWidget { ), ), const SizedBox(height: 12), - _ShimmerBox( + ShimmerBox( baseColor: placeholderColor, highlightColor: primaryColor, width: 250,