This commit is contained in:
Dmitry 2025-11-22 22:39:55 +03:00
parent 9b02605ebe
commit 0cfcc0f600
5 changed files with 197 additions and 109 deletions

View file

@ -1 +1 @@
1../mnemo_cards/fonts/Nunito-VariableFont_wght.ttf  asset1../mnemo_cards/fonts/Nunito-VariableFont_wght.ttf2packages/cupertino_icons/assets/CupertinoIcons.ttf  asset2packages/cupertino_icons/assets/CupertinoIcons.ttf 1../mnemo_cards/fonts/Nunito-VariableFont_wght.ttf  asset1../mnemo_cards/fonts/Nunito-VariableFont_wght.ttf icons/ad.webp  asset icons/ad.webpicons/back.webp  asseticons/back.webpicons/cards.webp  asseticons/cards.webpicons/chat.webp  asseticons/chat.webpicons/clock.webp  asseticons/clock.webpicons/crown.webp  asseticons/crown.webpicons/down.webp  asseticons/down.webpicons/download.webp  asseticons/download.webpicons/exit.webp  asseticons/exit.webpicons/exit2.webp  asseticons/exit2.webpicons/exit3.webp  asseticons/exit3.webpicons/gift.webp  asseticons/gift.webpicons/google.webp  asseticons/google.webpicons/heart.webp  asseticons/heart.webpicons/heart_fill.webp  asset1../mnemo_cards/fonts/Nunito-VariableFont_wght.ttf1../mnemo_cards/fonts/Nunito-VariableFont_wght.ttf  asset1../mnemo_cards/fonts/Nunito-VariableFont_wght.ttfasset1../mnemo_cards/fonts/Nunito-VariableFont_wght.ttfasseticons/mic_off.webpicons/mic_on.webp  asseticons/mic_on.webpicons/next.webp  asseticons/next.webpicons/profile.webp  asseticons/profile.webpicons/settings.webp  asseticons/settings.webpicons/shuffle.webp  asseticons/shuffle.webpicons/skull.webp  asseticons/skull.webpicons/small_circle.webp  asseticons/small_circle.webpicons/sound_off.webp  asseticons/sound_off.webpicons/sound_on.webp  asseticons/sound_on.webpicons/telegram.webp  asseticons/telegram.webp icons/tg.webp  asset icons/tg.webpicons/view.webp  asseticons/view.webp2packages/cupertino_icons/assets/CupertinoIcons.ttf  asset2packages/cupertino_icons/assets/CupertinoIcons.ttf

View file

@ -1 +1 @@
{"../mnemo_cards/fonts/Nunito-VariableFont_wght.ttf":["../mnemo_cards/fonts/Nunito-VariableFont_wght.ttf"],"packages/cupertino_icons/assets/CupertinoIcons.ttf":["packages/cupertino_icons/assets/CupertinoIcons.ttf"]} {"../mnemo_cards/fonts/Nunito-VariableFont_wght.ttf":["../mnemo_cards/fonts/Nunito-VariableFont_wght.ttf"],"icons/ad.webp":["icons/ad.webp"],"icons/back.webp":["icons/back.webp"],"icons/cards.webp":["icons/cards.webp"],"icons/chat.webp":["icons/chat.webp"],"icons/clock.webp":["icons/clock.webp"],"icons/crown.webp":["icons/crown.webp"],"icons/down.webp":["icons/down.webp"],"icons/download.webp":["icons/download.webp"],"icons/exit.webp":["icons/exit.webp"],"icons/exit2.webp":["icons/exit2.webp"],"icons/exit3.webp":["icons/exit3.webp"],"icons/gift.webp":["icons/gift.webp"],"icons/google.webp":["icons/google.webp"],"icons/heart.webp":["icons/heart.webp"],"icons/heart_fill.webp":["icons/heart_fill.webp"],"icons/lock.webp":["icons/lock.webp"],"icons/mic_off.webp":["icons/mic_off.webp"],"icons/mic_on.webp":["icons/mic_on.webp"],"icons/next.webp":["icons/next.webp"],"icons/profile.webp":["icons/profile.webp"],"icons/settings.webp":["icons/settings.webp"],"icons/shuffle.webp":["icons/shuffle.webp"],"icons/skull.webp":["icons/skull.webp"],"icons/small_circle.webp":["icons/small_circle.webp"],"icons/sound_off.webp":["icons/sound_off.webp"],"icons/sound_on.webp":["icons/sound_on.webp"],"icons/telegram.webp":["icons/telegram.webp"],"icons/tg.webp":["icons/tg.webp"],"icons/view.webp":["icons/view.webp"],"packages/cupertino_icons/assets/CupertinoIcons.ttf":["packages/cupertino_icons/assets/CupertinoIcons.ttf"]}

View file

@ -2,7 +2,6 @@ import 'dart:async';
import 'dart:developer'; import 'dart:developer';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:meta/meta.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
@ -11,7 +10,6 @@ import 'package:yx_scope_flutter/yx_scope_flutter.dart';
import 'package:mnemo_cards_web_v2/di/app_scope/app_scope_container.dart'; import 'package:mnemo_cards_web_v2/di/app_scope/app_scope_container.dart';
import 'package:mnemo_cards_web_v2/domain/config/api_config_v2.dart'; import 'package:mnemo_cards_web_v2/domain/config/api_config_v2.dart';
import 'package:mnemo_cards_web_v2/domain/models/telegram_auth_code_status.dart'; import 'package:mnemo_cards_web_v2/domain/models/telegram_auth_code_status.dart';
import 'package:mnemo_cards_web_v2/presentation/theme/app_colors.dart';
/// Authentication page /// Authentication page
/// ///
@ -33,8 +31,6 @@ class _AuthPageState extends State<AuthPage> {
DateTime? _codeExpiryTime; DateTime? _codeExpiryTime;
bool _autoLoginAttempted = false; bool _autoLoginAttempted = false;
static const String _telegramStartPrefix = 'login_';
Future<void> _loginWithGoogle(BuildContext context) async { Future<void> _loginWithGoogle(BuildContext context) async {
setState(() { setState(() {
_isLoading = true; _isLoading = true;
@ -216,6 +212,8 @@ class _AuthPageState extends State<AuthPage> {
if (mounted) { if (mounted) {
_startCodePolling(context, status.code); _startCodePolling(context, status.code);
// Automatically open Telegram bot with the code
await _openTelegramBotWithCode(status.code);
} }
} catch (e, s) { } catch (e, s) {
log( log(
@ -368,20 +366,16 @@ class _AuthPageState extends State<AuthPage> {
final isActive = !status.isExpired && !status.isConsumed; final isActive = !status.isExpired && !status.isConsumed;
return Container( return Card(
width: double.infinity, elevation: 2,
padding: const EdgeInsets.all(16), shape: RoundedRectangleBorder(
decoration: BoxDecoration(
color: theme.cardColor,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: Border.all(
color: theme.dividerColor,
width: 1,
),
), ),
child: Column( child: Padding(
crossAxisAlignment: CrossAxisAlignment.start, padding: const EdgeInsets.all(16),
children: [ child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row( Row(
children: [ children: [
Icon( Icon(
@ -457,32 +451,66 @@ class _AuthPageState extends State<AuthPage> {
Padding( Padding(
padding: const EdgeInsets.only(top: 8.0), padding: const EdgeInsets.only(top: 8.0),
child: Text( child: Text(
'Откройте бота и отправьте код, чтобы продолжить.', 'Бот должен открыться автоматически. Отправьте код в боте, чтобы продолжить.',
style: theme.textTheme.bodySmall?.copyWith( style: theme.textTheme.bodySmall?.copyWith(
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
color: theme.colorScheme.onSurfaceVariant, color: theme.colorScheme.onSurfaceVariant,
), ),
), ),
), ),
], ],
),
), ),
); );
} }
Future<void> _openTelegramBot() async { /// Open Telegram bot with a specific code in the start parameter
final code = _telegramCodeController.text.trim(); Future<void> _openTelegramBotWithCode(String code) async {
if (mounted && _errorMessage != null) { if (mounted && _errorMessage != null) {
setState(() { setState(() {
_errorMessage = null; _errorMessage = null;
}); });
} }
final deepLink = code.isEmpty // Format: https://t.me/bot_username?start=code
? Uri.parse(ApiConfigV2.telegramBotDeepLinkBase) // Bot extracts 6-digit code from payload using regex (\d{6})$
: Uri.parse( final deepLink = Uri.parse(
ApiConfigV2.telegramBotDeepLink('$_telegramStartPrefix$code'), ApiConfigV2.telegramBotDeepLink(code),
); );
try {
final launched = await launchUrl(
deepLink,
mode: LaunchMode.externalApplication,
);
if (!launched) {
throw Exception('Could not launch Telegram');
}
} catch (e, s) {
log(
'Error opening Telegram bot',
error: e,
stackTrace: s,
name: 'AuthPage',
);
if (mounted) {
setState(() {
_errorMessage =
'Не удалось открыть Telegram. Попробуйте вручную: ${deepLink.toString()}';
});
}
}
}
/// Open Telegram bot without code (base link)
Future<void> _openTelegramBot() async {
if (mounted && _errorMessage != null) {
setState(() {
_errorMessage = null;
});
}
final deepLink = Uri.parse(ApiConfigV2.telegramBotDeepLinkBase);
try { try {
final launched = await launchUrl( final launched = await launchUrl(
@ -541,72 +569,79 @@ class _AuthPageState extends State<AuthPage> {
), ),
child: ConstrainedBox( child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 480), constraints: const BoxConstraints(maxWidth: 480),
child: Column( child: Card(
mainAxisAlignment: MainAxisAlignment.center, elevation: 2,
crossAxisAlignment: CrossAxisAlignment.stretch, shape: RoundedRectangleBorder(
children: [ borderRadius: BorderRadius.circular(16),
Icon( ),
Icons.lock_outline, child: Padding(
size: 64, padding: const EdgeInsets.all(24.0),
color: theme.colorScheme.onSurface, child: Column(
), mainAxisSize: MainAxisSize.min,
const SizedBox(height: 24), crossAxisAlignment: CrossAxisAlignment.stretch,
Text( children: [
'Sign in to your account', Icon(
style: theme.textTheme.headlineMedium?.copyWith( Icons.lock_outline,
fontWeight: FontWeight.w700, size: 64,
color: theme.colorScheme.onSurface, color: theme.colorScheme.onSurface,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'Save your progress and sync data',
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w700,
color: theme.colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 32),
// Error message display
if (_errorMessage != null) ...[
Container(
padding: const EdgeInsets.all(16),
margin: const EdgeInsets.only(bottom: 24),
decoration: BoxDecoration(
color: Colors.red.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Colors.red.withOpacity(0.3),
width: 1,
),
), ),
child: SelectableText.rich( const SizedBox(height: 24),
TextSpan( Text(
children: [ 'Sign in to your account',
const WidgetSpan( style: theme.textTheme.headlineMedium?.copyWith(
child: Icon( fontWeight: FontWeight.w700,
Icons.error_outline, color: theme.colorScheme.onSurface,
color: Colors.red,
size: 20,
),
),
const WidgetSpan(child: SizedBox(width: 8)),
TextSpan(
text: _errorMessage,
style: TextStyle(
color: Colors.red,
fontWeight: FontWeight.w700,
fontSize: theme.textTheme.bodyMedium?.fontSize,
),
),
],
), ),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
), const SizedBox(height: 8),
], Text(
'Save your progress and sync data',
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w700,
color: theme.colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 32),
// Error message display
if (_errorMessage != null) ...[
Container(
padding: const EdgeInsets.all(16),
margin: const EdgeInsets.only(bottom: 24),
decoration: BoxDecoration(
color: Colors.red.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Colors.red.withValues(alpha: 0.3),
width: 1,
),
),
child: SelectableText.rich(
TextSpan(
children: [
const WidgetSpan(
child: Icon(
Icons.error_outline,
color: Colors.red,
size: 20,
),
),
const WidgetSpan(child: SizedBox(width: 8)),
TextSpan(
text: _errorMessage,
style: TextStyle(
color: Colors.red,
fontWeight: FontWeight.w700,
fontSize: theme.textTheme.bodyMedium?.fontSize,
),
),
],
),
textAlign: TextAlign.center,
),
),
],
// Login buttons // Login buttons
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
@ -817,7 +852,9 @@ class _AuthPageState extends State<AuthPage> {
), ),
), ),
), ),
], ],
),
),
), ),
), ),
), ),

View file

@ -1,29 +1,14 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
import 'package:mocktail/mocktail.dart';
import 'package:mnemo_cards_web_v2/di/app_scope/app_scope_container.dart';
import 'package:mnemo_cards_web_v2/presentation/pages/auth/auth_page.dart'; import 'package:mnemo_cards_web_v2/presentation/pages/auth/auth_page.dart';
import 'package:mnemo_cards_web_v2/presentation/theme/app_theme.dart'; import 'package:mnemo_cards_web_v2/presentation/theme/app_theme.dart';
import 'package:yx_scope_flutter/yx_scope_flutter.dart';
// Mock classes
class MockAppScopeContainer extends Mock implements AppScopeContainer {}
void main() { void main() {
late MockAppScopeContainer mockAppScope;
setUp(() {
mockAppScope = MockAppScopeContainer();
});
Widget createTestWidget(Widget child) { Widget createTestWidget(Widget child) {
return ScopeProvider<AppScopeContainer>( return MaterialApp(
scope: mockAppScope, theme: AppTheme.light,
child: MaterialApp( home: child,
theme: AppTheme.light,
home: child,
),
); );
} }
@ -255,5 +240,71 @@ void main() {
final buttonWidget = tester.widget<OutlinedButton>(outlinedButton); final buttonWidget = tester.widget<OutlinedButton>(outlinedButton);
expect(buttonWidget.style, isNotNull); expect(buttonWidget.style, isNotNull);
}); });
testWidgets('main content is wrapped in Card with elevation 2', (tester) async {
await tester.pumpWidget(createTestWidget(const AuthPage()));
// Find Card widgets
final cards = find.byType(Card);
expect(cards, findsWidgets);
// Main content card should have elevation 2
final cardWidgets = tester.widgetList<Card>(cards);
expect(cardWidgets.isNotEmpty, true);
expect(cardWidgets.first.elevation, 2);
expect(cardWidgets.first.shape, isA<RoundedRectangleBorder>());
});
testWidgets('main card has rounded corners with 16px radius', (tester) async {
await tester.pumpWidget(createTestWidget(const AuthPage()));
final cards = find.byType(Card);
final cardWidgets = tester.widgetList<Card>(cards);
expect(cardWidgets.isNotEmpty, true);
final mainCard = cardWidgets.first;
final shape = mainCard.shape as RoundedRectangleBorder?;
expect(shape, isNotNull);
expect(shape?.borderRadius, BorderRadius.circular(16));
});
testWidgets('telegram status card uses Card with elevation 2', (tester) async {
await tester.pumpWidget(createTestWidget(const AuthPage()));
// Status card should not be visible initially
// But we can verify the structure exists
final cards = find.byType(Card);
expect(cards, findsWidgets);
// All cards should have elevation 2
final cardWidgets = tester.widgetList<Card>(cards);
for (final card in cardWidgets) {
expect(card.elevation, 2);
}
});
testWidgets('main card has proper padding', (tester) async {
await tester.pumpWidget(createTestWidget(const AuthPage()));
final cards = find.byType(Card);
expect(cards, findsWidgets);
// Card should have a Padding child
final paddingFinder = find.descendant(
of: cards.first,
matching: find.byType(Padding),
);
expect(paddingFinder, findsOneWidget);
final padding = tester.widget<Padding>(paddingFinder);
expect(padding.padding, const EdgeInsets.all(24.0));
});
testWidgets('uses withValues instead of deprecated withOpacity', (tester) async {
await tester.pumpWidget(createTestWidget(const AuthPage()));
// Verify that error container uses withValues (not withOpacity)
// This is tested by ensuring the code compiles without deprecation warnings
expect(find.byType(Container), findsWidgets);
});
}); });
} }