diff --git a/mobile-app/lib/features/components/link_text.dart b/mobile-app/lib/features/components/link_text.dart new file mode 100644 index 00000000..e9331513 --- /dev/null +++ b/mobile-app/lib/features/components/link_text.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; +import 'package:resonance_network_wallet/features/styles/app_text_theme.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class LinkText extends StatelessWidget { + final String label; + final String url; + final TextStyle? textStyle; + + const LinkText({super.key, required this.label, required this.url, this.textStyle}); + + @override + Widget build(BuildContext context) { + final effectiveTextStyle = (textStyle ?? context.themeText.paragraph)?.copyWith( + decoration: TextDecoration.underline, + ); + + return GestureDetector( + child: Text(label, style: effectiveTextStyle), + onTap: () { + final Uri uri = Uri.parse(url); + launchUrl(uri); + }, + ); + } +} diff --git a/mobile-app/lib/features/components/list_item.dart b/mobile-app/lib/features/components/list_item.dart new file mode 100644 index 00000000..f6bdd25a --- /dev/null +++ b/mobile-app/lib/features/components/list_item.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; +import 'package:resonance_network_wallet/features/styles/app_colors_theme.dart'; +import 'package:resonance_network_wallet/features/styles/app_size_theme.dart'; +import 'package:resonance_network_wallet/features/styles/app_text_theme.dart'; +import 'package:resonance_network_wallet/shared/extensions/media_query_data_extension.dart'; + +class ListItem extends StatelessWidget { + final String title; + final VoidCallback onTap; + final Widget? trailing; + final bool showArrow; + + const ListItem({super.key, required this.title, required this.onTap, this.trailing, this.showArrow = true}); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + width: double.infinity, + padding: EdgeInsets.symmetric(vertical: context.isTablet ? 16 : 12, horizontal: 18), + decoration: ShapeDecoration( + color: context.themeColors.buttonGlass, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text(title, style: context.themeText.smallParagraph), + trailing ?? + (showArrow + ? Icon(Icons.arrow_forward_ios, size: context.themeSize.settingMenuIconSize) + : const SizedBox()), + ], + ), + ), + ); + } +} diff --git a/mobile-app/lib/features/components/raid_submission_action_sheet.dart b/mobile-app/lib/features/components/raid_submission_action_sheet.dart new file mode 100644 index 00000000..733b3c04 --- /dev/null +++ b/mobile-app/lib/features/components/raid_submission_action_sheet.dart @@ -0,0 +1,235 @@ +import 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:quantus_sdk/quantus_sdk.dart'; +import 'package:resonance_network_wallet/features/components/button.dart'; +import 'package:resonance_network_wallet/features/components/custom_text_field.dart'; +import 'package:resonance_network_wallet/features/styles/app_colors_theme.dart'; +import 'package:resonance_network_wallet/features/styles/app_size_theme.dart'; +import 'package:resonance_network_wallet/features/styles/app_text_theme.dart'; +import 'package:resonance_network_wallet/providers/raider_quest_providers.dart'; +import 'package:resonance_network_wallet/shared/extensions/media_query_data_extension.dart'; +import 'package:resonance_network_wallet/shared/extensions/snackbar_extensions.dart'; +import 'package:resonance_network_wallet/utils/validators.dart'; + +class RaidSubmissionActionSheet extends ConsumerStatefulWidget { + const RaidSubmissionActionSheet({super.key}); + + @override + ConsumerState createState() => _RaidSubmissionActionSheetState(); +} + +class _RaidSubmissionActionSheetState extends ConsumerState { + final _taskmasterService = TaskmasterService(); + + final _targetTweetController = TextEditingController(); + final _replyTweetController = TextEditingController(); + + bool _isSubmitting = false; + bool _isDisabled = true; + + String? _targetErrorMsg; + String? _replyErrorMsg; + String? _errorMsg; + + @override + void initState() { + super.initState(); + + _targetTweetController.addListener(_checkFormValidity); + _replyTweetController.addListener(_checkFormValidity); + } + + void _checkFormValidity() { + String targetInput = _targetTweetController.text.trim(); + String replyInput = _replyTweetController.text.trim(); + + bool targetTweetIsValid = Validators.isValidXStatusUrl(targetInput); + bool replyTweetIsValid = Validators.isValidXStatusUrl(replyInput); + + String errMsg = 'Invalid X status link.'; + + setState(() { + _isDisabled = !targetTweetIsValid || !replyTweetIsValid; + _errorMsg = null; + _targetErrorMsg = targetTweetIsValid ? null : errMsg; + _replyErrorMsg = replyTweetIsValid ? null : errMsg; + }); + } + + void _closeSheet() { + Navigator.of(context).pop(); + } + + Future _handleSubmit(String targetLink, String replyLink) async { + setState(() { + _isSubmitting = true; + }); + + try { + await _taskmasterService.addRaidSubmission(targetLink, replyLink); + if (mounted) { + context.showSuccessSnackbar(title: 'Success submitted!', message: 'Success adding raid submission!'); + } + ref.invalidate(raiderSubmissionsProvider); + _closeSheet(); + } catch (e) { + print('Failed adding raid submission: $e'); + + setState(() { + _isSubmitting = false; + _errorMsg = e.toString(); + }); + } + } + + @override + Widget build(BuildContext context) { + final height = MediaQuery.of(context).size.height; + + final effectiveHeight = height * 0.76; + final effectiveRadius = 5.0; + final effectivePadding = const EdgeInsets.symmetric(horizontal: 24, vertical: 16); + + return SafeArea( + child: Container( + height: effectiveHeight, + padding: effectivePadding, + decoration: ShapeDecoration( + color: Colors.black, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(effectiveRadius)), + ), + child: _buildForm(context), + ), + ); + } + + Widget _buildForm(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: double.infinity, + padding: const EdgeInsets.all(7), + decoration: ShapeDecoration(shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(100))), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + InkWell( + onTap: _isSubmitting ? null : _closeSheet, + child: Icon(Icons.close, size: context.isTablet ? 28 : 24), + ), + ], + ), + ), + const SizedBox(height: 22), + Text('Raid Submission', style: context.themeText.mediumTitle?.copyWith(fontWeight: FontWeight.w600)), + SizedBox(height: context.isTablet ? 32 : 24), + Text( + 'Have you conducted a raid on a target? Enter your raid detail here:', + style: context.themeText.smallParagraph?.copyWith(color: context.themeColors.inputLabel), + ), + const SizedBox(height: 12), + CustomTextField( + controller: _targetTweetController, + labelText: 'Target Tweet Link', + fillColor: context.themeColors.background, + trailing: InkWell( + onTap: () async { + final data = await Clipboard.getData('text/plain'); + if (data != null && data.text != null) { + _targetTweetController.text = data.text!; + } + }, + child: SvgPicture.asset('assets/paste_icon_1.svg', width: context.isTablet ? 24 : 18), + ), + errorMsg: _targetErrorMsg, + ), + const SizedBox(height: 8), + CustomTextField( + controller: _replyTweetController, + labelText: 'Reply Tweet Link', + fillColor: context.themeColors.background, + trailing: InkWell( + onTap: () async { + final data = await Clipboard.getData('text/plain'); + if (data != null && data.text != null) { + _replyTweetController.text = data.text!; + } + }, + child: SvgPicture.asset('assets/paste_icon_1.svg', width: context.isTablet ? 24 : 18), + ), + errorMsg: _replyErrorMsg, + ), + const SizedBox(height: 24), + const Spacer(), + if (_errorMsg != null) + Center( + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(_errorMsg!, style: context.themeText.detail?.copyWith(color: context.themeColors.textError)), + const SizedBox(height: 4), + ], + ), + ), + SizedBox( + width: context.isTablet ? 465 : null, + child: Button( + label: 'Submit', + isLoading: _isSubmitting, + isDisabled: _isDisabled, + variant: ButtonVariant.primary, + onPressed: () { + _handleSubmit(_targetTweetController.text, _replyTweetController.text); + }, + ), + ), + + SizedBox(height: context.themeSize.bottomButtonSpacing), + ], + ); + } +} + +void showRaidSubmissionActionSheet( + BuildContext context, { + String? referralCode, + bool? directlyShowRewardProgram, + bool showRewardProgram = true, +}) { + showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + isScrollControlled: true, + constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width), + builder: (context) => Stack( + children: [ + Positioned.fill( + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Colors.black, const Color(0xFF312E6E).useOpacity(0.4), Colors.black], + ), + ), + ), + ), + Positioned( + bottom: 0, + left: 0, + right: 0, + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 3, sigmaY: 3), + child: Container(color: Colors.black.useOpacity(0.3), child: const RaidSubmissionActionSheet()), + ), + ), + ], + ), + ); +} diff --git a/mobile-app/lib/features/main/screens/navbar.dart b/mobile-app/lib/features/main/screens/navbar.dart index 9ce1a67c..66e977da 100644 --- a/mobile-app/lib/features/main/screens/navbar.dart +++ b/mobile-app/lib/features/main/screens/navbar.dart @@ -5,7 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; import 'package:quantus_sdk/quantus_sdk.dart'; import 'package:resonance_network_wallet/features/components/referral_and_reward_action_sheet.dart'; -import 'package:resonance_network_wallet/features/main/screens/quests_screen.dart'; +import 'package:resonance_network_wallet/features/main/screens/quests/quests_screen.dart'; import 'package:resonance_network_wallet/features/main/screens/settings_screen.dart'; import 'package:resonance_network_wallet/features/main/screens/transactions_screen.dart'; import 'package:resonance_network_wallet/features/main/screens/wallet_main/wallet_main.dart'; diff --git a/mobile-app/lib/features/main/screens/account_associations_screen.dart b/mobile-app/lib/features/main/screens/quests/account_associations_screen.dart similarity index 98% rename from mobile-app/lib/features/main/screens/account_associations_screen.dart rename to mobile-app/lib/features/main/screens/quests/account_associations_screen.dart index 9c68c1a9..80d089d5 100644 --- a/mobile-app/lib/features/main/screens/account_associations_screen.dart +++ b/mobile-app/lib/features/main/screens/quests/account_associations_screen.dart @@ -14,6 +14,7 @@ import 'package:resonance_network_wallet/features/components/wallet_app_bar.dart import 'package:resonance_network_wallet/features/styles/app_colors_theme.dart'; import 'package:resonance_network_wallet/features/styles/app_text_theme.dart'; import 'package:resonance_network_wallet/providers/account_associations_providers.dart'; +import 'package:resonance_network_wallet/providers/raider_quest_providers.dart'; import 'package:resonance_network_wallet/shared/extensions/clipboard_extensions.dart'; import 'package:resonance_network_wallet/shared/extensions/media_query_data_extension.dart'; import 'package:resonance_network_wallet/shared/extensions/snackbar_extensions.dart'; @@ -134,6 +135,7 @@ class _AccountAssociationsScreenState extends ConsumerState Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + spacing: 12.0, + children: [ + Flexible( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildAssociationCard( + context, + title: titleEth, + isLoading: false, + isAssociated: associations.ethAddress != null, + ), + _buildAssociationCard( + context, + title: titleX, + isLoading: false, + isAssociated: associations.xUsername != null, + ), + ], + ), + ), + InkWell( + child: SvgPicture.asset( + 'assets/settings_icon_off.svg', + width: context.isTablet ? 28 : 21, + colorFilter: const ColorFilter.mode(Colors.white, BlendMode.srcIn), + ), + onTap: () { + Navigator.push(context, MaterialPageRoute(builder: (context) => const AccountAssociationsScreen())); + }, + ), + ], + ), + loading: () => Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + spacing: 12.0, + children: [ + Flexible( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildAssociationCard(context, title: titleEth, isLoading: true), + _buildAssociationCard(context, title: titleX, isLoading: true), + ], + ), + ), + InkWell( + child: SvgPicture.asset( + 'assets/settings_icon_off.svg', + width: context.isTablet ? 28 : 21, + colorFilter: const ColorFilter.mode(Colors.white, BlendMode.srcIn), + ), + ), + ], + ), + error: (error, stack) => Column( + children: [ + Text( + 'Error fetching associated accounts.', + style: context.themeText.detail?.copyWith(color: context.themeColors.textError), + ), + const SizedBox(height: 12), + Button( + variant: ButtonVariant.neutral, + label: 'Try again', + onPressed: () { + refreshAssociationsData(ref); + }, + ), + ], + ), + ); + } + + Widget _buildAssociationCard( + BuildContext context, { + required String title, + required bool isLoading, + bool isAssociated = false, + }) { + Widget getIcon() { + if (isLoading) return const Skeleton(width: 40, height: 16); + if (isAssociated == false) return Icon(Icons.close, color: context.themeColors.buttonDanger); + + return Icon(Icons.check, color: context.themeColors.buttonSuccess); + } + + return BasicCard( + padding: const EdgeInsets.symmetric(vertical: 2, horizontal: 14), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(title, style: context.themeText.paragraph), + getIcon(), + ], + ), + ); + } +} diff --git a/mobile-app/lib/features/main/screens/quests/king_of_the_raider_screen.dart b/mobile-app/lib/features/main/screens/quests/king_of_the_raider_screen.dart new file mode 100644 index 00000000..605ad2ae --- /dev/null +++ b/mobile-app/lib/features/main/screens/quests/king_of_the_raider_screen.dart @@ -0,0 +1,254 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:quantus_sdk/quantus_sdk.dart'; +import 'package:resonance_network_wallet/features/components/basic_card.dart'; +import 'package:resonance_network_wallet/features/components/button.dart'; +import 'package:resonance_network_wallet/features/components/link_text.dart'; +import 'package:resonance_network_wallet/features/components/raid_submission_action_sheet.dart'; +import 'package:resonance_network_wallet/features/components/scaffold_base.dart'; +import 'package:resonance_network_wallet/features/components/sphere.dart'; +import 'package:resonance_network_wallet/features/components/wallet_app_bar.dart'; +import 'package:resonance_network_wallet/features/main/screens/quests/account_associations_status.dart'; +import 'package:resonance_network_wallet/features/main/screens/quests/optin_position_status.dart'; +import 'package:resonance_network_wallet/features/main/screens/quests/quest_title.dart'; +import 'package:resonance_network_wallet/features/styles/app_colors_theme.dart'; +import 'package:resonance_network_wallet/features/styles/app_text_theme.dart'; +import 'package:resonance_network_wallet/providers/raider_quest_providers.dart'; +import 'package:resonance_network_wallet/shared/extensions/media_query_data_extension.dart'; +import 'package:resonance_network_wallet/shared/extensions/snackbar_extensions.dart'; + +class KingOfTheRaiderScreen extends ConsumerStatefulWidget { + const KingOfTheRaiderScreen({super.key}); + + @override + ConsumerState createState() => _KingOfTheRaiderScreenState(); +} + +class _KingOfTheRaiderScreenState extends ConsumerState with WidgetsBindingObserver { + final _taskmasterService = TaskmasterService(); + final ScrollController _scrollController = ScrollController(); + + @override + void initState() { + super.initState(); + } + + @override + void dispose() { + super.dispose(); + } + + void refreshRaiderSubmissions() { + ref.invalidate(raiderSubmissionsProvider); + } + + String? extractXStatusId(String url) { + final uri = Uri.tryParse(url); + if (uri == null) return null; + + // Expected path: /{username}/status/{id} + final segments = uri.pathSegments; + + if (segments.length >= 3 && segments[1] == 'status') { + final id = segments[2]; + return RegExp(r'^\d+$').hasMatch(id) ? id : null; + } + + return null; + } + + Future _handleRemoveSubmission(String id) async { + try { + await _taskmasterService.removeRaidSubmission(id); + if (mounted) { + context.showSuccessSnackbar(title: 'Success removed!', message: 'Success removing raid submission!'); + } + ref.invalidate(raiderSubmissionsProvider); + } catch (e) { + print('Failed removing raid submission: $e'); + + if (mounted) { + context.showErrorSnackbar(title: 'Failed removing!', message: e.toString()); + } + } + } + + @override + Widget build(BuildContext context) { + final raiderSubmissionsAsync = ref.watch(raiderSubmissionsProvider); + final effectiveSpacing = context.isSmallHeight ? 24.0 : 36.0; + + return ScaffoldBase.refreshable( + appBar: WalletAppBar(title: 'King of The Raider'), + onRefresh: () async { + refreshRaiderSubmissions(); + }, + scrollController: _scrollController, + decorations: [ + const Positioned(top: 180, right: -34, child: Sphere(variant: 2, size: 194)), + const Positioned(left: -60, bottom: 0, child: Sphere(variant: 7, size: 240.68)), + ], + slivers: [ + SliverToBoxAdapter( + child: Stack( + children: [ + Padding( + padding: const EdgeInsets.only(top: 11), + child: Column( + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 17, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 96), + const Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [OptinPositionStatus()], + ), + SizedBox(height: effectiveSpacing), + const AccountAssociationsStatus(), + SizedBox(height: effectiveSpacing), + ...raiderSubmissionsAsync.when( + loading: () => [ + Center(child: CircularProgressIndicator(color: context.themeColors.circularLoader)), + ], + error: (error, stackTrace) => [ + Text( + 'Error fetching raider submissions.', + style: context.themeText.detail?.copyWith(color: context.themeColors.textError), + ), + const SizedBox(height: 12), + Button( + variant: ButtonVariant.neutral, + label: 'Try again', + onPressed: refreshRaiderSubmissions, + ), + ], + data: (state) { + switch (state) { + case RaiderSubmissionsOk(): + return [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 4, + children: [ + BasicCard( + child: Row( + children: [ + Text('Active Raid: ', style: context.themeText.smallTitle), + Text('Alpha', style: context.themeText.smallTitle), + ], + ), + ), + LinkText( + label: 'Learn more about QQ', + url: AppConstants.raidQuestsPageUrl, + textStyle: context.themeText.smallParagraph, + ), + ], + ), + SizedBox(height: effectiveSpacing), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text('Raid Submissions', style: context.themeText.smallTitle), + const SizedBox(width: 12), + InkWell( + child: Container( + decoration: ShapeDecoration( + color: context.themeColors.buttonNeutral, + shape: RoundedRectangleBorder( + borderRadius: BorderRadiusGeometry.circular(8), + ), + ), + child: const Icon(Icons.add, color: Colors.black), + ), + onTap: () { + showRaidSubmissionActionSheet(context); + }, + ), + ], + ), + const SizedBox(height: 8), + if (state.submissions.isNotEmpty) + Column( + spacing: 4, + children: state.submissions.asMap().entries.map((entry) { + final index = entry.key + 1; + final value = entry.value; + final label = extractXStatusId(value) ?? 'Unknown'; + + return Row( + children: [ + Text('$index. '), + LinkText( + label: label, + url: value, + textStyle: context.themeText.smallParagraph, + ), + InkWell( + child: Icon(Icons.delete, color: context.themeColors.buttonDanger), + onTap: () { + _handleRemoveSubmission(label); + }, + ), + ], + ); + }).toList(), + ) + else + Text( + "You haven't submitted anything yet", + style: context.themeText.smallParagraph, + ), + ]; + + case NoActiveRaid(): + return [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 4, + children: [ + BasicCard( + child: Row( + children: [ + Text('No Active Raid: ', style: context.themeText.smallTitle), + ], + ), + ), + LinkText( + label: 'Learn more about QQ', + url: AppConstants.raidQuestsPageUrl, + textStyle: context.themeText.smallParagraph, + ), + ], + ), + ]; + + case NoTwitterLinked(): + return [Text('Please link your X account', style: context.themeText.smallTitle)]; + } + }, + ), + ], + ), + ), + ], + ), + SizedBox(height: context.isSmallHeight ? 18 : 40), + ], + ), + ), + const QuestTitle(), + ], + ), + ), + ], + ); + } +} diff --git a/mobile-app/lib/features/main/screens/quests/king_of_the_shill_screen.dart b/mobile-app/lib/features/main/screens/quests/king_of_the_shill_screen.dart new file mode 100644 index 00000000..8a7e067e --- /dev/null +++ b/mobile-app/lib/features/main/screens/quests/king_of_the_shill_screen.dart @@ -0,0 +1,244 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:quantus_sdk/quantus_sdk.dart'; +import 'package:resonance_network_wallet/features/components/basic_card.dart'; +import 'package:resonance_network_wallet/features/components/button.dart'; +import 'package:resonance_network_wallet/features/components/link_text.dart'; +import 'package:resonance_network_wallet/features/components/scaffold_base.dart'; +import 'package:resonance_network_wallet/features/components/skeleton.dart'; +import 'package:resonance_network_wallet/features/components/sphere.dart'; +import 'package:resonance_network_wallet/features/components/wallet_app_bar.dart'; +import 'package:resonance_network_wallet/features/main/screens/quests/account_associations_status.dart'; +import 'package:resonance_network_wallet/features/main/screens/quests/optin_position_status.dart'; +import 'package:resonance_network_wallet/features/main/screens/quests/quest_title.dart'; +import 'package:resonance_network_wallet/features/styles/app_colors_theme.dart'; +import 'package:resonance_network_wallet/features/styles/app_text_theme.dart'; +import 'package:resonance_network_wallet/providers/account_associations_providers.dart'; +import 'package:resonance_network_wallet/providers/account_stats_providers.dart'; +import 'package:resonance_network_wallet/services/referral_service.dart'; +import 'package:resonance_network_wallet/shared/extensions/clipboard_extensions.dart'; +import 'package:resonance_network_wallet/shared/extensions/media_query_data_extension.dart'; +import 'package:share_plus/share_plus.dart'; + +class KingOfTheShillScreen extends ConsumerStatefulWidget { + const KingOfTheShillScreen({super.key}); + + @override + ConsumerState createState() => _KingOfTheShillScreenState(); +} + +class _KingOfTheShillScreenState extends ConsumerState with WidgetsBindingObserver { + final ReferralService _referralService = ReferralService(); + final ScrollController _scrollController = ScrollController(); + + String? _referralCode; + + Future _loadReferralCode() async { + try { + final myReferralCode = await _referralService.getMyInviteCode(); + setState(() { + _referralCode = myReferralCode; + }); + } catch (e) { + debugPrint('Error loading account data: $e'); + if (mounted) { + setState(() {}); + } + } + } + + Future _shareReferralLink() async { + final params = await _referralService.getShareLinkParameters(context.sharePositionRect()); + SharePlus.instance.share(params); + } + + void _copyReferralCode() { + if (_referralCode != null) { + ClipboardExtensions.copyTextWithSnackbar(context, _referralCode!, message: 'Referral code copied to clipboard'); + } + } + + @override + void initState() { + super.initState(); + _loadReferralCode(); + } + + @override + void dispose() { + super.dispose(); + } + + void refreshStatsData() { + ref.invalidate(accountsStatsProvider); + } + + void refreshAssociationsData() { + ref.invalidate(accountAssociationsProvider); + } + + @override + Widget build(BuildContext context) { + final statsAsync = ref.watch(accountsStatsProvider); + + return ScaffoldBase.refreshable( + appBar: WalletAppBar(title: 'King of The Shill'), + padding: const EdgeInsetsGeometry.all(0), + onRefresh: () async { + refreshStatsData(); + refreshAssociationsData(); + }, + scrollController: _scrollController, + decorations: [ + const Positioned(top: 180, right: -34, child: Sphere(variant: 2, size: 194)), + const Positioned(left: -60, bottom: 0, child: Sphere(variant: 7, size: 240.68)), + ], + slivers: [ + SliverToBoxAdapter( + child: Stack( + children: [ + Padding( + padding: const EdgeInsets.only(top: 11), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.only(right: 44), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 17, + children: [ + _buildDecoration(), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 96), + const Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [OptinPositionStatus(), SizedBox(width: 71)], + ), + SizedBox(height: context.isSmallHeight ? 18 : 37.0), + const AccountAssociationsStatus(), + SizedBox(height: context.isSmallHeight ? 18 : 37.0), + ..._buildAccountStats(context, statsAsync), + const SizedBox(height: 16), + LinkText( + label: 'Learn more about QQ', + url: AppConstants.shillQuestsPageUrl, + textStyle: context.themeText.smallParagraph, + ), + ], + ), + ), + ], + ), + ), + SizedBox(height: context.isSmallHeight ? 18 : 40), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24.0), + child: InkWell( + onTap: _copyReferralCode, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 11), + decoration: ShapeDecoration( + color: context.themeColors.background, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)), + ), + child: Row( + spacing: 12.0, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + _referralCode ?? 'Loading...', + style: context.themeText.smallParagraph, + textAlign: TextAlign.center, + ), + Icon(Icons.copy, color: Colors.white, size: context.isTablet ? 26 : 22), + ], + ), + ), + ), + ), + const SizedBox(height: 16), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Button( + variant: ButtonVariant.glassOutline, + label: 'Share Referral Link', + onPressed: _shareReferralLink, + ), + ), + ], + ), + ), + const QuestTitle(), + ], + ), + ), + ], + ); + } + + List _buildAccountStats(BuildContext context, AsyncValue statsAsync) { + return statsAsync.when( + data: (stats) => [ + _buildStatCard(context, 'Referrals:', stats.referralCount), + const SizedBox(height: 9), + _buildStatCard(context, 'Sends:', stats.sendCount), + const SizedBox(height: 9), + _buildStatCard(context, 'Reversals:', stats.reversalCount), + const SizedBox(height: 9), + _buildStatCard(context, 'Mining:', stats.miningCount), + ], + loading: () => [ + _buildStatCard(context, 'Referrals:', null), + const SizedBox(height: 9), + _buildStatCard(context, 'Sends:', null), + const SizedBox(height: 9), + _buildStatCard(context, 'Reversals:', null), + const SizedBox(height: 9), + _buildStatCard(context, 'Mining:', null), + ], + error: (error, stack) => [ + Text( + 'Error fetching account stats.', + style: context.themeText.detail?.copyWith(color: context.themeColors.textError), + ), + const SizedBox(height: 12), + Button(variant: ButtonVariant.neutral, label: 'Try again', onPressed: refreshStatsData), + ], + ); + } + + Widget _buildStatCard(BuildContext context, String title, int? stat) { + final isLoading = stat == null; + + return BasicCard( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(title, style: context.themeText.smallTitle), + isLoading ? const Skeleton(width: 40, height: 16) : Text('$stat', style: context.themeText.smallTitle), + ], + ), + ); + } + + Widget _buildDecoration() { + return Container( + width: 85, + height: context.isSmallHeight ? 415 : 480, + decoration: const ShapeDecoration( + gradient: LinearGradient( + begin: Alignment(0.03, -1.00), + end: Alignment(-0.03, 1), + colors: [Color(0xFF0000FF), Color(0xFFED4CCE), Color(0xFFFFE91F)], + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.only(topRight: Radius.circular(10), bottomRight: Radius.circular(10)), + ), + ), + ); + } +} diff --git a/mobile-app/lib/features/main/screens/quests/optin_position_status.dart b/mobile-app/lib/features/main/screens/quests/optin_position_status.dart new file mode 100644 index 00000000..fecd0000 --- /dev/null +++ b/mobile-app/lib/features/main/screens/quests/optin_position_status.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:resonance_network_wallet/features/components/skeleton.dart'; +import 'package:resonance_network_wallet/features/styles/app_colors_theme.dart'; +import 'package:resonance_network_wallet/features/styles/app_text_theme.dart'; +import 'package:resonance_network_wallet/providers/opt_in_position_providers.dart'; + +class OptinPositionStatus extends ConsumerWidget { + const OptinPositionStatus({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final positionAsync = ref.watch(optInPositionProvider); + + return positionAsync.when( + data: (pos) => Text( + 'Rewards no. #${pos.position}', + style: context.themeText.smallParagraph?.copyWith(fontWeight: FontWeight.w600), + ), + loading: () => Row( + children: [ + Text('Rewards no. ', style: context.themeText.smallParagraph?.copyWith(fontWeight: FontWeight.w600)), + const Skeleton(width: 30, height: 16), + ], + ), + error: (error, stack) => Text( + 'Error fetching opted in position.', + style: context.themeText.detail?.copyWith(color: context.themeColors.textError), + ), + ); + } +} diff --git a/mobile-app/lib/features/main/screens/quests/quest_title.dart b/mobile-app/lib/features/main/screens/quests/quest_title.dart new file mode 100644 index 00000000..f461bd71 --- /dev/null +++ b/mobile-app/lib/features/main/screens/quests/quest_title.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; + +class QuestTitle extends StatelessWidget { + final EdgeInsetsGeometry padding; + + const QuestTitle({super.key, this.padding = const EdgeInsetsGeometry.only(top: 24)}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: padding, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + spacing: 11, + children: [ + Image.asset('assets/navbar/qcat_navbar_icon.png', width: 82), + Image.asset('assets/qq-logo.png', width: 226.35), + ], + ), + ); + } +} diff --git a/mobile-app/lib/features/main/screens/quests/quests_screen.dart b/mobile-app/lib/features/main/screens/quests/quests_screen.dart new file mode 100644 index 00000000..1889db83 --- /dev/null +++ b/mobile-app/lib/features/main/screens/quests/quests_screen.dart @@ -0,0 +1,238 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:quantus_sdk/quantus_sdk.dart'; +import 'package:resonance_network_wallet/features/components/button.dart'; +import 'package:resonance_network_wallet/features/components/list_item.dart'; +import 'package:resonance_network_wallet/features/components/loading_text_animation.dart'; +import 'package:resonance_network_wallet/features/components/quests_promo_video.dart'; +import 'package:resonance_network_wallet/features/components/scaffold_base.dart'; +import 'package:resonance_network_wallet/features/components/sphere.dart'; +import 'package:resonance_network_wallet/features/main/screens/navbar.dart'; +import 'package:resonance_network_wallet/features/main/screens/quests/account_associations_status.dart'; +import 'package:resonance_network_wallet/features/main/screens/quests/king_of_the_raider_screen.dart'; +import 'package:resonance_network_wallet/features/main/screens/quests/king_of_the_shill_screen.dart'; +import 'package:resonance_network_wallet/features/main/screens/quests/optin_position_status.dart'; +import 'package:resonance_network_wallet/features/main/screens/quests/quest_title.dart'; +import 'package:resonance_network_wallet/features/styles/app_text_theme.dart'; +import 'package:resonance_network_wallet/providers/account_associations_providers.dart'; +import 'package:resonance_network_wallet/providers/account_stats_providers.dart'; +import 'package:resonance_network_wallet/providers/opt_in_position_providers.dart'; +import 'package:resonance_network_wallet/services/referral_service.dart'; +import 'package:resonance_network_wallet/shared/extensions/media_query_data_extension.dart'; + +class QuestsScreen extends ConsumerStatefulWidget { + const QuestsScreen({super.key}); + + @override + ConsumerState createState() => _QuestsScreenState(); +} + +class _QuestsScreenState extends ConsumerState { + final ReferralService _referralService = ReferralService(); + final ScrollController _scrollController = ScrollController(); + + bool _isRewardProgramParticipant = false; + bool _isLoadingParticipation = true; + bool _isLastPromo = false; + bool _isSubmitting = false; + bool _isVisible = true; + + Future _loadParticipationStatus() async { + try { + final isParticipant = await _referralService.getRewardProgramParticiation(); + setState(() { + _isRewardProgramParticipant = isParticipant; + _isLoadingParticipation = false; + }); + } catch (e) { + debugPrint('Error loading participation status: $e'); + setState(() { + _isLoadingParticipation = false; + }); + } + } + + @override + void initState() { + super.initState(); + _loadParticipationStatus(); + } + + @override + void dispose() { + super.dispose(); + } + + void refreshStatsData() { + ref.invalidate(accountsStatsProvider); + } + + void refreshAssociationsData() { + ref.invalidate(accountAssociationsProvider); + } + + void setVideoVisibility(bool isVisible) { + if (mounted) { + setState(() { + _isVisible = isVisible; + }); + } + } + + void _setIsFinalVideo(bool isFinalVideo) { + setState(() { + _isLastPromo = isFinalVideo; + }); + } + + Future _handleOptIn(BuildContext context) async { + setState(() { + _isSubmitting = true; + }); + + try { + await _referralService.optInRewardProgram(); + ref.invalidate(optInPositionProvider); + + setState(() { + _isSubmitting = false; + }); + + if (mounted) { + Navigator.pushAndRemoveUntil( + this.context, + MaterialPageRoute( + settings: const RouteSettings(name: 'navbar'), + builder: (context) => const Navbar(initialIndex: 3), + ), + (route) => false, + ); + } + } catch (e) { + print('Failed opting in reward program: $e'); + setState(() { + _isSubmitting = false; + }); + } + } + + @override + Widget build(BuildContext context) { + // Show videos for users who haven't opted in to the reward program + if (_isLoadingParticipation) { + return const ScaffoldBase( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + spacing: 12, + children: [ + QuestTitle(padding: EdgeInsetsGeometry.zero), + LoadingTextAnimation(), + ], + ), + ), + ); + } + + if (!_isRewardProgramParticipant) { + return Scaffold( + backgroundColor: Colors.black, + body: Stack( + children: [ + QuestsPromoVideo( + isSubmitting: _isSubmitting, + closeSheet: null, // No close button for inline use + setIsFinalVideo: _setIsFinalVideo, + startFromBeginning: true, + showCloseButton: false, + isVisible: _isVisible, + ), + if (_isLastPromo) + Positioned( + bottom: 100, // Move down to avoid video text overlap + left: 0, + right: 0, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Colors.transparent, Colors.black.useOpacity(0.8)], + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Button( + label: "I'm In", + isLoading: _isSubmitting, + variant: ButtonVariant.primary, + onPressed: () { + _handleOptIn(context); + }, + ), + ], + ), + ), + ), + ], + ), + ); + } + + return ScaffoldBase.refreshable( + onRefresh: () async { + refreshStatsData(); + refreshAssociationsData(); + }, + scrollController: _scrollController, + decorations: [ + const Positioned(top: 180, right: -34, child: Sphere(variant: 2, size: 194)), + const Positioned(left: -60, bottom: 0, child: Sphere(variant: 7, size: 240.68)), + ], + slivers: [ + SliverToBoxAdapter( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const QuestTitle(), + const OptinPositionStatus(), + SizedBox(height: context.isSmallHeight ? 18 : 37.0), + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 4, + children: [ + Text('Associated Accounts', style: context.themeText.smallParagraph, textAlign: TextAlign.start), + const AccountAssociationsStatus(), + ], + ), + ], + ), + ), + + SliverToBoxAdapter(child: SizedBox(height: context.isSmallHeight ? 18 : 37.0)), + SliverToBoxAdapter(child: Text('Quests', style: context.themeText.smallParagraph)), + const SliverToBoxAdapter(child: SizedBox(height: 4)), + SliverList( + delegate: SliverChildListDelegate([ + ListItem( + title: 'King of The Raider', + onTap: () { + Navigator.push(context, MaterialPageRoute(builder: (context) => const KingOfTheRaiderScreen())); + }, + ), + const SizedBox(height: 12), + ListItem( + title: 'King of The Shill', + onTap: () { + Navigator.push(context, MaterialPageRoute(builder: (context) => const KingOfTheShillScreen())); + }, + ), + ]), + ), + ], + ); + } +} diff --git a/mobile-app/lib/features/main/screens/quests_screen.dart b/mobile-app/lib/features/main/screens/quests_screen.dart deleted file mode 100644 index 7b7babbc..00000000 --- a/mobile-app/lib/features/main/screens/quests_screen.dart +++ /dev/null @@ -1,513 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_svg/svg.dart'; -import 'package:quantus_sdk/quantus_sdk.dart'; -import 'package:resonance_network_wallet/features/components/basic_card.dart'; -import 'package:resonance_network_wallet/features/components/button.dart'; -import 'package:resonance_network_wallet/features/components/loading_text_animation.dart'; -import 'package:resonance_network_wallet/features/components/quests_promo_video.dart'; -import 'package:resonance_network_wallet/features/components/scaffold_base.dart'; -import 'package:resonance_network_wallet/features/components/skeleton.dart'; -import 'package:resonance_network_wallet/features/components/sphere.dart'; -import 'package:resonance_network_wallet/features/main/screens/account_associations_screen.dart'; -import 'package:resonance_network_wallet/features/main/screens/navbar.dart'; -import 'package:resonance_network_wallet/features/styles/app_colors_theme.dart'; -import 'package:resonance_network_wallet/features/styles/app_text_theme.dart'; -import 'package:resonance_network_wallet/providers/account_associations_providers.dart'; -import 'package:resonance_network_wallet/providers/account_stats_providers.dart'; -import 'package:resonance_network_wallet/providers/opt_in_position_providers.dart'; -import 'package:resonance_network_wallet/services/referral_service.dart'; -import 'package:resonance_network_wallet/shared/extensions/clipboard_extensions.dart'; -import 'package:resonance_network_wallet/shared/extensions/media_query_data_extension.dart'; -import 'package:share_plus/share_plus.dart'; -import 'package:url_launcher/url_launcher.dart'; - -class QuestsScreen extends ConsumerStatefulWidget { - const QuestsScreen({super.key}); - - @override - ConsumerState createState() => _QuestsScreenState(); -} - -class _QuestsScreenState extends ConsumerState with WidgetsBindingObserver { - final ReferralService _referralService = ReferralService(); - final ScrollController _scrollController = ScrollController(); - - String? _referralCode; - bool _isRewardProgramParticipant = false; - bool _isLoadingParticipation = true; - bool _isLastPromo = false; - bool _isSubmitting = false; - bool _isVisible = true; - - Future _loadReferralCode() async { - try { - final myReferralCode = await _referralService.getMyInviteCode(); - setState(() { - _referralCode = myReferralCode; - }); - } catch (e) { - debugPrint('Error loading account data: $e'); - if (mounted) { - setState(() {}); - } - } - } - - Future _loadParticipationStatus() async { - try { - final isParticipant = await _referralService.getRewardProgramParticiation(); - setState(() { - _isRewardProgramParticipant = isParticipant; - _isLoadingParticipation = false; - }); - } catch (e) { - debugPrint('Error loading participation status: $e'); - setState(() { - _isLoadingParticipation = false; - }); - } - } - - Future _shareReferralLink() async { - final params = await _referralService.getShareLinkParameters(context.sharePositionRect()); - SharePlus.instance.share(params); - } - - void _copyReferralCode() { - if (_referralCode != null) { - ClipboardExtensions.copyTextWithSnackbar(context, _referralCode!, message: 'Referral code copied to clipboard'); - } - } - - @override - void initState() { - super.initState(); - _loadReferralCode(); - _loadParticipationStatus(); - } - - @override - void dispose() { - super.dispose(); - } - - void refreshStatsData() { - ref.invalidate(accountsStatsProvider); - } - - void refreshAssociationsData() { - ref.invalidate(accountAssociationsProvider); - } - - void setVideoVisibility(bool isVisible) { - if (mounted) { - setState(() { - _isVisible = isVisible; - }); - } - } - - void _setIsFinalVideo(bool isFinalVideo) { - setState(() { - _isLastPromo = isFinalVideo; - }); - } - - Future _handleOptIn(BuildContext context) async { - setState(() { - _isSubmitting = true; - }); - - try { - await _referralService.optInRewardProgram(); - ref.invalidate(optInPositionProvider); - - setState(() { - _isSubmitting = false; - }); - - if (mounted) { - Navigator.pushAndRemoveUntil( - this.context, - MaterialPageRoute( - settings: const RouteSettings(name: 'navbar'), - builder: (context) => const Navbar(initialIndex: 3), - ), - (route) => false, - ); - } - } catch (e) { - print('Failed opting in reward program: $e'); - setState(() { - _isSubmitting = false; - }); - } - } - - @override - Widget build(BuildContext context) { - final statsAsync = ref.watch(accountsStatsProvider); - final associationsAsync = ref.watch(accountAssociationsProvider); - final positionAsync = ref.watch(optInPositionProvider); - - // Show videos for users who haven't opted in to the reward program - if (_isLoadingParticipation) { - return ScaffoldBase( - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - spacing: 12, - children: [_buildQuestTitle(), const LoadingTextAnimation()], - ), - ), - ); - } - - if (!_isRewardProgramParticipant) { - return Scaffold( - backgroundColor: Colors.black, - body: Stack( - children: [ - QuestsPromoVideo( - isSubmitting: _isSubmitting, - closeSheet: null, // No close button for inline use - setIsFinalVideo: _setIsFinalVideo, - startFromBeginning: true, - showCloseButton: false, - isVisible: _isVisible, - ), - if (_isLastPromo) - Positioned( - bottom: 100, // Move down to avoid video text overlap - left: 0, - right: 0, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [Colors.transparent, Colors.black.useOpacity(0.8)], - ), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Button( - label: "I'm In", - isLoading: _isSubmitting, - variant: ButtonVariant.primary, - onPressed: () { - _handleOptIn(context); - }, - ), - ], - ), - ), - ), - ], - ), - ); - } - - return ScaffoldBase.refreshable( - padding: const EdgeInsetsGeometry.all(0), - onRefresh: () async { - refreshStatsData(); - refreshAssociationsData(); - }, - scrollController: _scrollController, - decorations: [ - const Positioned(top: 180, right: -34, child: Sphere(variant: 2, size: 194)), - const Positioned(left: -60, bottom: 0, child: Sphere(variant: 7, size: 240.68)), - ], - slivers: [ - SliverToBoxAdapter( - child: Stack( - children: [ - Padding( - padding: const EdgeInsets.only(top: 11), - child: Column( - children: [ - Padding( - padding: const EdgeInsets.only(right: 44), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - spacing: 17, - children: [ - _buildDecoration(), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: 96), - Row( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.center, - children: [_buildOptInPosition(context, positionAsync), const SizedBox(width: 71)], - ), - SizedBox(height: context.isSmallHeight ? 18 : 37.0), - _buildAccountAssociations(context, associationsAsync), - SizedBox(height: context.isSmallHeight ? 18 : 37.0), - ..._buildAccountStats(context, statsAsync), - const SizedBox(height: 16), - GestureDetector( - child: Text( - 'Learn more about QQ', - style: context.themeText.smallParagraph?.copyWith( - decoration: TextDecoration.underline, - ), - ), - onTap: () { - final Uri url = Uri.parse(AppConstants.questsPageUrl); - launchUrl(url); - }, - ), - ], - ), - ), - ], - ), - ), - SizedBox(height: context.isSmallHeight ? 18 : 40), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 24.0), - child: InkWell( - onTap: _copyReferralCode, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 11), - decoration: ShapeDecoration( - color: context.themeColors.background, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)), - ), - child: Row( - spacing: 12.0, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - _referralCode ?? 'Loading...', - style: context.themeText.smallParagraph, - textAlign: TextAlign.center, - ), - Icon(Icons.copy, color: Colors.white, size: context.isTablet ? 26 : 22), - ], - ), - ), - ), - ), - const SizedBox(height: 16), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 24), - child: Button( - variant: ButtonVariant.glassOutline, - label: 'Share Referral Link', - onPressed: _shareReferralLink, - ), - ), - ], - ), - ), - Padding(padding: const EdgeInsets.only(top: 24), child: _buildQuestTitle()), - ], - ), - ), - ], - ); - } - - Widget _buildQuestTitle() { - return Row( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.center, - spacing: 11, - children: [ - Image.asset('assets/navbar/qcat_navbar_icon.png', width: 82), - Image.asset('assets/qq-logo.png', width: 226.35), - ], - ); - } - - Widget _buildOptInPosition(BuildContext context, AsyncValue positionAsync) { - return positionAsync.when( - data: (pos) => Text( - 'Rewards no. #${pos.position}', - style: context.themeText.smallParagraph?.copyWith(fontWeight: FontWeight.w600), - ), - loading: () => Row( - children: [ - Text('Rewards no. ', style: context.themeText.smallParagraph?.copyWith(fontWeight: FontWeight.w600)), - const Skeleton(width: 30, height: 16), - ], - ), - error: (error, stack) => Text( - 'Error fetching opted in position.', - style: context.themeText.detail?.copyWith(color: context.themeColors.textError), - ), - ); - } - - List _buildAccountStats(BuildContext context, AsyncValue statsAsync) { - return statsAsync.when( - data: (stats) => [ - _buildStatCard(context, 'Referrals:', stats.referralCount), - const SizedBox(height: 9), - _buildStatCard(context, 'Sends:', stats.sendCount), - const SizedBox(height: 9), - _buildStatCard(context, 'Reversals:', stats.reversalCount), - const SizedBox(height: 9), - _buildStatCard(context, 'Mining:', stats.miningCount), - ], - loading: () => [ - _buildStatCard(context, 'Referrals:', null), - const SizedBox(height: 9), - _buildStatCard(context, 'Sends:', null), - const SizedBox(height: 9), - _buildStatCard(context, 'Reversals:', null), - const SizedBox(height: 9), - _buildStatCard(context, 'Mining:', null), - ], - error: (error, stack) => [ - Text( - 'Error fetching account stats.', - style: context.themeText.detail?.copyWith(color: context.themeColors.textError), - ), - const SizedBox(height: 12), - Button(variant: ButtonVariant.neutral, label: 'Try again', onPressed: refreshStatsData), - ], - ); - } - - Widget _buildAccountAssociations(BuildContext context, AsyncValue associationsAsync) { - final titleEth = 'ETH Address'; - final titleX = 'X Account'; - - return associationsAsync.when( - data: (associations) => Row( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - spacing: 12.0, - children: [ - Flexible( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - _buildAssociationCard( - context, - title: titleEth, - isLoading: false, - isAssociated: associations.ethAddress != null, - ), - _buildAssociationCard( - context, - title: titleX, - isLoading: false, - isAssociated: associations.xUsername != null, - ), - ], - ), - ), - InkWell( - child: SvgPicture.asset( - 'assets/settings_icon_off.svg', - width: context.isTablet ? 28 : 21, - colorFilter: const ColorFilter.mode(Colors.white, BlendMode.srcIn), - ), - onTap: () { - Navigator.push(context, MaterialPageRoute(builder: (context) => const AccountAssociationsScreen())); - }, - ), - ], - ), - loading: () => Row( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - spacing: 12.0, - children: [ - Flexible( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - _buildAssociationCard(context, title: titleEth, isLoading: true), - _buildAssociationCard(context, title: titleX, isLoading: true), - ], - ), - ), - InkWell( - child: SvgPicture.asset( - 'assets/settings_icon_off.svg', - width: context.isTablet ? 28 : 21, - colorFilter: const ColorFilter.mode(Colors.white, BlendMode.srcIn), - ), - ), - ], - ), - error: (error, stack) => Column( - children: [ - Text( - 'Error fetching associated accounts.', - style: context.themeText.detail?.copyWith(color: context.themeColors.textError), - ), - const SizedBox(height: 12), - Button(variant: ButtonVariant.neutral, label: 'Try again', onPressed: refreshAssociationsData), - ], - ), - ); - } - - Widget _buildAssociationCard( - BuildContext context, { - required String title, - required bool isLoading, - bool isAssociated = false, - }) { - Widget getIcon() { - if (isLoading) return const Skeleton(width: 40, height: 16); - if (isAssociated == false) return Icon(Icons.close, color: context.themeColors.buttonDanger); - - return Icon(Icons.check, color: context.themeColors.buttonSuccess); - } - - return BasicCard( - padding: const EdgeInsets.symmetric(vertical: 2, horizontal: 14), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text(title, style: context.themeText.paragraph), - getIcon(), - ], - ), - ); - } - - Widget _buildStatCard(BuildContext context, String title, int? stat) { - final isLoading = stat == null; - - return BasicCard( - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text(title, style: context.themeText.smallTitle), - isLoading ? const Skeleton(width: 40, height: 16) : Text('$stat', style: context.themeText.smallTitle), - ], - ), - ); - } - - Widget _buildDecoration() { - return Container( - width: 85, - height: context.isSmallHeight ? 415 : 480, - decoration: const ShapeDecoration( - gradient: LinearGradient( - begin: Alignment(0.03, -1.00), - end: Alignment(-0.03, 1), - colors: [Color(0xFF0000FF), Color(0xFFED4CCE), Color(0xFFFFE91F)], - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.only(topRight: Radius.circular(10), bottomRight: Radius.circular(10)), - ), - ), - ); - } -} diff --git a/mobile-app/lib/features/main/screens/settings_screen.dart b/mobile-app/lib/features/main/screens/settings_screen.dart index f573d512..e73e7b1f 100644 --- a/mobile-app/lib/features/main/screens/settings_screen.dart +++ b/mobile-app/lib/features/main/screens/settings_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:quantus_sdk/quantus_sdk.dart'; import 'package:resonance_network_wallet/features/components/app_modal_bottom_sheet.dart'; +import 'package:resonance_network_wallet/features/components/list_item.dart'; import 'package:resonance_network_wallet/features/components/referral_and_reward_action_sheet.dart'; import 'package:resonance_network_wallet/features/components/reset_confirmation_bottom_sheet.dart'; import 'package:resonance_network_wallet/features/components/scaffold_base.dart'; @@ -132,28 +133,43 @@ class _SettingsScreenState extends ConsumerState { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildSettingsItem(context, 'Manage Accounts', () { - Navigator.push(context, MaterialPageRoute(builder: (context) => const AccountsScreen())); - }), + ListItem( + title: 'Manage Accounts', + onTap: () { + Navigator.push(context, MaterialPageRoute(builder: (context) => const AccountsScreen())); + }, + ), const SizedBox(height: 22), - _buildSettingsItem(context, 'Notifications', () { - Navigator.push(context, MaterialPageRoute(builder: (context) => const NotificationsSettingsScreen())); - }), + ListItem( + title: 'Notifications', + onTap: () { + Navigator.push(context, MaterialPageRoute(builder: (context) => const NotificationsSettingsScreen())); + }, + ), const SizedBox(height: 22), - _buildSettingsItem(context, 'Authentication', () { - Navigator.push(context, MaterialPageRoute(builder: (context) => const AuthenticationSettingsScreen())); - }), + ListItem( + title: 'Authentication', + onTap: () { + Navigator.push(context, MaterialPageRoute(builder: (context) => const AuthenticationSettingsScreen())); + }, + ), const SizedBox(height: 22), - _buildSettingsItem(context, 'Show Recovery Phrase', () { - Navigator.push(context, MaterialPageRoute(builder: (context) => const ShowRecoveryPhraseScreen())); - }), + ListItem( + title: 'Show Recovery Phrase', + onTap: () { + Navigator.push(context, MaterialPageRoute(builder: (context) => const ShowRecoveryPhraseScreen())); + }, + ), const SizedBox(height: 22), - _buildSettingsItem(context, 'Referral', () { - showReferralAndRewardActionSheet( - context, - showRewardProgram: false, // only show referral code section - ); - }), + ListItem( + title: 'Referral', + onTap: () { + showReferralAndRewardActionSheet( + context, + showRewardProgram: false, // only show referral code section + ); + }, + ), ], ); } @@ -164,10 +180,9 @@ class _SettingsScreenState extends ConsumerState { children: [ _buildSectionTitle('Information'), const SizedBox(height: 14), - _buildSettingsItem( - context, - 'Help & Support', - () { + ListItem( + title: 'Help & Support', + onTap: () { final Uri url = Uri.parse(AppConstants.helpAndSupportUrl); launchUrl(url); }, @@ -175,17 +190,15 @@ class _SettingsScreenState extends ConsumerState { showArrow: false, ), const SizedBox(height: 22), - _buildSettingsItem( - context, - 'Invite & Share', - _share, + ListItem( + title: 'Invite & Share', + onTap: _share, trailing: Icon(Icons.share_outlined, size: context.themeSize.settingMenuShareIconSize), ), const SizedBox(height: 22), - _buildSettingsItem( - context, - 'Term of Service', - () { + ListItem( + title: 'Term of Service', + onTap: () { final Uri url = Uri.parse(AppConstants.termsOfServiceUrl); launchUrl(url); }, @@ -196,37 +209,6 @@ class _SettingsScreenState extends ConsumerState { ); } - Widget _buildSettingsItem( - BuildContext context, - String title, - VoidCallback onTap, { - Widget? trailing, - bool showArrow = true, - }) { - return GestureDetector( - onTap: onTap, - child: Container( - width: double.infinity, - padding: EdgeInsets.symmetric(vertical: context.isTablet ? 16 : 12, horizontal: 18), - decoration: ShapeDecoration( - color: context.themeColors.buttonGlass, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Text(title, style: context.themeText.smallParagraph), - trailing ?? - (showArrow - ? Icon(Icons.arrow_forward_ios, size: context.themeSize.settingMenuIconSize) - : const SizedBox()), - ], - ), - ), - ); - } - Widget _buildResetButton(BuildContext context) { return GestureDetector( onTap: _showResetConfirmationSheet, diff --git a/mobile-app/lib/providers/raider_quest_providers.dart b/mobile-app/lib/providers/raider_quest_providers.dart new file mode 100644 index 00000000..1bb3a496 --- /dev/null +++ b/mobile-app/lib/providers/raider_quest_providers.dart @@ -0,0 +1,43 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:quantus_sdk/quantus_sdk.dart'; +import 'package:resonance_network_wallet/providers/account_providers.dart'; + +class RaiderSubmissionsNotifier extends StateNotifier> { + final TaskmasterService _taskmasterService = TaskmasterService(); + final Account? _account; + + RaiderSubmissionsNotifier(this._account) : super(const AsyncValue.loading()) { + if (_account != null) { + fetchRaiderSubmissions(); + } + } + + Future fetchRaiderSubmissions() async { + if (_account == null) return; + + try { + final submissions = await _taskmasterService.getActiveRaidRaiderSubmissions(); + if (mounted) { + state = AsyncValue.data(submissions); + } + } catch (e, st) { + print('Error fetching raider submissions: $e'); + print('Stack trace: $st'); + + if (mounted) { + state = AsyncValue.error(e, st); + } + } + } + + void reset() { + state = const AsyncValue.loading(); + } +} + +final raiderSubmissionsProvider = StateNotifierProvider>(( + ref, +) { + final activeAccount = ref.watch(activeAccountProvider).value; + return RaiderSubmissionsNotifier(activeAccount); +}); diff --git a/mobile-app/lib/utils/validators.dart b/mobile-app/lib/utils/validators.dart new file mode 100644 index 00000000..1fccffd7 --- /dev/null +++ b/mobile-app/lib/utils/validators.dart @@ -0,0 +1,9 @@ +class Validators { + static bool isValidXStatusUrl(String url) { + final regex = RegExp( + r'^https?:\/\/(www\.|mobile\.)?(x\.com|twitter\.com)\/[A-Za-z0-9_]{1,15}\/status\/\d{10,25}(\?.*)?$', + ); + + return regex.hasMatch(url); + } +} diff --git a/mobile-app/test/unit/validators_test.dart b/mobile-app/test/unit/validators_test.dart new file mode 100644 index 00000000..f57931db --- /dev/null +++ b/mobile-app/test/unit/validators_test.dart @@ -0,0 +1,40 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:resonance_network_wallet/utils/validators.dart'; + +void main() { + group('Validators', () { + test('isValidXStatusUrl should validate correct X and Twitter URLs', () { + final validUrls = [ + 'https://x.com/username/status/123456789012345', + 'https://www.x.com/username/status/123456789012345', + 'http://x.com/username/status/123456789012345', + 'https://twitter.com/username/status/123456789012345', + 'https://mobile.twitter.com/username/status/123456789012345', + 'https://mobile.x.com/username/status/123456789012345', + 'https://x.com/username/status/123456789012345?s=20', + 'https://twitter.com/User_Name/status/1234567890', + ]; + + for (final url in validUrls) { + expect(Validators.isValidXStatusUrl(url), isTrue, reason: 'URL should be valid: $url'); + } + }); + + test('isValidXStatusUrl should reject invalid URLs', () { + final invalidUrls = [ + 'https://google.com', + 'https://x.com/username', + 'https://x.com/status/12345', // Missing username + 'https://x.com/username/12345', // Missing status + 'ftp://x.com/username/status/12345', // Invalid protocol + 'https://other-domain.com/username/status/12345', + '', + 'random text', + ]; + + for (final url in invalidUrls) { + expect(Validators.isValidXStatusUrl(url), isFalse, reason: 'URL should be invalid: $url'); + } + }); + }); +} diff --git a/quantus_sdk/lib/quantus_sdk.dart b/quantus_sdk/lib/quantus_sdk.dart index 7cd6695c..05e98bdc 100644 --- a/quantus_sdk/lib/quantus_sdk.dart +++ b/quantus_sdk/lib/quantus_sdk.dart @@ -28,6 +28,8 @@ export 'src/models/reversible_transfer_status.dart'; export 'src/models/sorted_transactions.dart'; export 'src/models/transaction_event.dart'; export 'src/models/transaction_state.dart'; +export 'src/models/raider_submissions.dart'; +export 'src/models/raid_quest.dart'; // note we have to hide some things here because they're exported by substrate service // should probably expise all of crypto.dart through substrateservice instead export 'src/rust/api/crypto.dart' hide crystalAlice, crystalCharlie, crystalBob; diff --git a/quantus_sdk/lib/src/constants/app_constants.dart b/quantus_sdk/lib/src/constants/app_constants.dart index f938ae93..b73fca23 100644 --- a/quantus_sdk/lib/src/constants/app_constants.dart +++ b/quantus_sdk/lib/src/constants/app_constants.dart @@ -25,7 +25,8 @@ class AppConstants { static const String helpAndSupportUrl = 'https://t.me/quantustechsupport'; static const String termsOfServiceUrl = 'https://www.quantus.com/terms-and-privacy'; static const String tutorialsAndGuidesUrl = 'https://github.com/Quantus-Network/chain'; - static const String questsPageUrl = 'https://www.quantus.com/quests'; + static const String shillQuestsPageUrl = 'https://www.quantus.com/quests/shill'; + static const String raidQuestsPageUrl = 'https://www.quantus.com/quests/raid'; static const String communityUrl = 'https://t.me/quantusnetwork'; static const String faucetBotUrl = 'https://t.me/QuantusFaucetBot'; diff --git a/quantus_sdk/lib/src/models/raid_quest.dart b/quantus_sdk/lib/src/models/raid_quest.dart new file mode 100644 index 00000000..5c86f7dd --- /dev/null +++ b/quantus_sdk/lib/src/models/raid_quest.dart @@ -0,0 +1,39 @@ +class RaidQuest { + final int id; + final String name; + final DateTime startDate; + final DateTime? endDate; + final DateTime updatedAt; + final DateTime createdAt; + + RaidQuest({ + required this.id, + required this.name, + required this.startDate, + this.endDate, + required this.updatedAt, + required this.createdAt, + }); + + factory RaidQuest.fromJson(Map json) { + return RaidQuest( + id: json['id'] as int, + name: json['name'] as String, + startDate: DateTime.parse(json['start_date'] as String), + endDate: json['end_date'] != null ? DateTime.parse(json['end_date'] as String) : null, + updatedAt: DateTime.parse(json['updated_at'] as String), + createdAt: DateTime.parse(json['created_at'] as String), + ); + } + + Map toJson() { + return { + 'id': id, + 'name': name, + 'start_date': startDate.toUtc().toIso8601String(), + 'end_date': endDate?.toUtc().toIso8601String(), + 'updated_at': updatedAt.toUtc().toIso8601String(), + 'created_at': createdAt.toUtc().toIso8601String(), + }; + } +} diff --git a/quantus_sdk/lib/src/models/raider_submissions.dart b/quantus_sdk/lib/src/models/raider_submissions.dart new file mode 100644 index 00000000..b623da6b --- /dev/null +++ b/quantus_sdk/lib/src/models/raider_submissions.dart @@ -0,0 +1,20 @@ +import 'package:quantus_sdk/quantus_sdk.dart'; + +sealed class RaiderSubmissionsState { + const RaiderSubmissionsState(); +} + +class RaiderSubmissionsOk extends RaiderSubmissionsState { + final RaidQuest activeRaid; + final List submissions; + + const RaiderSubmissionsOk({required this.activeRaid, required this.submissions}); +} + +class NoActiveRaid extends RaiderSubmissionsState { + const NoActiveRaid(); +} + +class NoTwitterLinked extends RaiderSubmissionsState { + const NoTwitterLinked(); +} diff --git a/quantus_sdk/lib/src/services/taskmaster_service.dart b/quantus_sdk/lib/src/services/taskmaster_service.dart index 3656b271..53fc6dda 100644 --- a/quantus_sdk/lib/src/services/taskmaster_service.dart +++ b/quantus_sdk/lib/src/services/taskmaster_service.dart @@ -245,6 +245,74 @@ class TaskmasterService { } } + Future addRaidSubmission(String targetTweetLink, String replyTweetLink) async { + print('add raid submission $targetTweetLink and $replyTweetLink'); + + final raiderSubmissionsEndpoint = Uri.parse('${AppConstants.taskMasterEndpoint}/raid-quests/submissions'); + final Map requestBody = {'target_tweet_link': targetTweetLink, 'tweet_reply_link': replyTweetLink}; + + final http.Response response = await _authenticatedHttpClient.post( + raiderSubmissionsEndpoint, + body: jsonEncode(requestBody), + ); + + if (response.statusCode != 201) { + throw Exception('Error ${response.statusCode}: ${response.body}'); + } + } + + Future removeRaidSubmission(String id) async { + print('Remove raid submission $id'); + + final raiderSubmissionsEndpoint = Uri.parse('${AppConstants.taskMasterEndpoint}/raid-quests/submissions/$id'); + final Map requestBody = {}; + + final http.Response response = await _authenticatedHttpClient.delete( + raiderSubmissionsEndpoint, + body: jsonEncode(requestBody), + ); + + if (response.statusCode != 204) { + throw Exception('Error ${response.statusCode}: ${response.body}'); + } + } + + Future getActiveRaidRaiderSubmissions() async { + final activeAccount = await getMainAccount(); + print('getActiveRaidRaiderSubmissions ${activeAccount.accountId}'); + final raiderSubmissionsEndpoint = Uri.parse('${AppConstants.taskMasterEndpoint}/raid-quests/submissions/me'); + + final http.Response response = await _authenticatedHttpClient.get( + raiderSubmissionsEndpoint, + headers: {'Content-Type': 'application/json'}, + ); + + final Map responseBody = jsonDecode(response.body); + + if (response.statusCode == 404) { + final error = (responseBody['error'] as String?)?.toLowerCase(); + + if (error == 'no active raid is found') { + return const NoActiveRaid(); + } else if (error == "user doesn't have x association") { + return const NoTwitterLinked(); + } + } + + if (response.statusCode != 200) { + throw Exception( + 'Get raider submissions http request failed with status: ${response.statusCode}. Body: ${response.body}', + ); + } + + final data = responseBody['data'] as Map?; + + return RaiderSubmissionsOk( + activeRaid: RaidQuest.fromJson(data?['current_raid']), + submissions: List.from(data?['submissions']), + ); + } + Future associateEthAddress(String ethAddress) async { print('associateEthAddress $ethAddress');