feat(mobile-app): add import mnemonic page and fix share URL

- Add import mnemonic page for account recovery
- Add serial number input field for recovery
- Add RecoverAccountResponse class for API response parsing
- Fix share page URL path (remove duplicate /api)
- Fix bottom nav bar height for better adaptability
- Update routes for import mnemonic page

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
hailin 2025-12-07 13:35:40 -08:00
parent c1670d2439
commit cfd5bd9bde
10 changed files with 758 additions and 40 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

View File

@ -175,6 +175,44 @@ String _maskAddress(String? address) {
return '${address.substring(0, 6)}...${address.substring(address.length - 4)}';
}
///
class RecoverAccountResponse {
final String userId;
final int userSerialNum; // accountSequence
final String username; // nickname
final String? avatarSvg; // avatarUrl
final String referralCode;
final String accessToken;
final String refreshToken;
RecoverAccountResponse({
required this.userId,
required this.userSerialNum,
required this.username,
this.avatarSvg,
required this.referralCode,
required this.accessToken,
required this.refreshToken,
});
factory RecoverAccountResponse.fromJson(Map<String, dynamic> json) {
debugPrint('[AccountService] 解析 RecoverAccountResponse: ${json.keys.toList()}');
return RecoverAccountResponse(
userId: json['userId'] as String,
userSerialNum: json['accountSequence'] as int,
username: json['nickname'] as String,
avatarSvg: json['avatarUrl'] as String?,
referralCode: json['referralCode'] as String,
accessToken: json['accessToken'] as String,
refreshToken: json['refreshToken'] as String,
);
}
@override
String toString() =>
'RecoverAccountResponse(userSerialNum: $userSerialNum, username: $username, referralCode: $referralCode)';
}
///
///
///
@ -596,6 +634,142 @@ class AccountService {
return result;
}
///
///
/// 使 12
/// [accountSequence] -
/// [mnemonic] - 12
Future<RecoverAccountResponse> recoverByMnemonic(int accountSequence, String mnemonic) async {
debugPrint('$_tag recoverByMnemonic() - 开始恢复账户');
debugPrint('$_tag recoverByMnemonic() - 序列号: $accountSequence');
try {
// ID
final deviceId = await getDeviceId();
debugPrint('$_tag recoverByMnemonic() - 获取设备ID成功');
//
final deviceInfo = await getDeviceHardwareInfo();
debugPrint('$_tag recoverByMnemonic() - 获取设备硬件信息成功');
// API
debugPrint('$_tag recoverByMnemonic() - 调用 POST /user/recover-by-mnemonic');
final response = await _apiClient.post(
'/user/recover-by-mnemonic',
data: {
'accountSequence': accountSequence,
'mnemonic': mnemonic,
'newDeviceId': deviceId,
'deviceName': '${deviceInfo.brand ?? ''} ${deviceInfo.model ?? ''}'.trim(),
},
);
debugPrint('$_tag recoverByMnemonic() - API 响应状态码: ${response.statusCode}');
if (response.data == null) {
debugPrint('$_tag recoverByMnemonic() - 错误: API 返回空响应');
throw const ApiException('恢复账户失败: 空响应');
}
debugPrint('$_tag recoverByMnemonic() - 解析响应数据');
final responseData = response.data as Map<String, dynamic>;
final data = responseData['data'] as Map<String, dynamic>;
final result = RecoverAccountResponse.fromJson(data);
debugPrint('$_tag recoverByMnemonic() - 解析成功: $result');
// (使)
debugPrint('$_tag recoverByMnemonic() - 保存账号数据');
await _saveRecoverAccountData(result, deviceId);
//
debugPrint('$_tag recoverByMnemonic() - 保存助记词');
await _secureStorage.write(
key: StorageKeys.mnemonic,
value: mnemonic,
);
// ()
await _secureStorage.write(
key: StorageKeys.isMnemonicBackedUp,
value: 'true',
);
debugPrint('$_tag recoverByMnemonic() - 账户恢复完成');
return result;
} on ApiException catch (e) {
debugPrint('$_tag recoverByMnemonic() - API 异常: $e');
rethrow;
} catch (e, stackTrace) {
debugPrint('$_tag recoverByMnemonic() - 未知异常: $e');
debugPrint('$_tag recoverByMnemonic() - 堆栈: $stackTrace');
throw ApiException('恢复账户失败: $e');
}
}
///
Future<void> _saveRecoverAccountData(
RecoverAccountResponse response,
String deviceId,
) async {
debugPrint('$_tag _saveRecoverAccountData() - 开始保存恢复账号数据');
//
debugPrint('$_tag _saveRecoverAccountData() - 保存 userSerialNum: ${response.userSerialNum}');
await _secureStorage.write(
key: StorageKeys.userSerialNum,
value: response.userSerialNum.toString(),
);
debugPrint('$_tag _saveRecoverAccountData() - 保存 referralCode: ${response.referralCode}');
await _secureStorage.write(
key: StorageKeys.referralCode,
value: response.referralCode,
);
//
debugPrint('$_tag _saveRecoverAccountData() - 保存 username: ${response.username}');
await _secureStorage.write(
key: StorageKeys.username,
value: response.username,
);
if (response.avatarSvg != null) {
debugPrint('$_tag _saveRecoverAccountData() - 保存 avatarSvg (长度: ${response.avatarSvg!.length})');
await _secureStorage.write(
key: StorageKeys.avatarSvg,
value: response.avatarSvg!,
);
}
// Token
debugPrint('$_tag _saveRecoverAccountData() - 保存 accessToken (长度: ${response.accessToken.length})');
await _secureStorage.write(
key: StorageKeys.accessToken,
value: response.accessToken,
);
debugPrint('$_tag _saveRecoverAccountData() - 保存 refreshToken (长度: ${response.refreshToken.length})');
await _secureStorage.write(
key: StorageKeys.refreshToken,
value: response.refreshToken,
);
// ID
debugPrint('$_tag _saveRecoverAccountData() - 保存 deviceId: ${_maskAddress(deviceId)}');
await _secureStorage.write(
key: StorageKeys.deviceId,
value: deviceId,
);
//
debugPrint('$_tag _saveRecoverAccountData() - 标记账号已创建');
await _secureStorage.write(
key: StorageKeys.isAccountCreated,
value: 'true',
);
debugPrint('$_tag _saveRecoverAccountData() - 恢复账号数据保存完成');
}
///
Future<void> logout() async {
debugPrint('$_tag logout() - 开始登出,清除所有数据');

View File

@ -111,11 +111,11 @@ class ReferralService {
/// ()
///
/// GET /api/me
/// GET /me
Future<MeResponse> getMe() async {
try {
debugPrint('获取用户信息...');
final response = await _apiClient.get('/api/me');
final response = await _apiClient.get('/me');
if (response.statusCode == 200) {
final data = response.data as Map<String, dynamic>;
@ -132,7 +132,7 @@ class ReferralService {
/// ()
///
/// POST /api/referrals/links
/// POST /referrals/links
/// shortUrl fullUrl
///
/// [channel] - : wechat, telegram, twitter
@ -145,7 +145,7 @@ class ReferralService {
debugPrint('生成推荐链接: channel=$channel, campaignId=$campaignId');
final response = await _apiClient.post(
'/api/referrals/links',
'/referrals/links',
data: {
if (channel != null) 'channel': channel,
if (campaignId != null) 'campaignId': campaignId,

View File

@ -0,0 +1,523 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../../core/di/injection_container.dart';
import '../../../../routes/route_paths.dart';
/// -
/// 12
class ImportMnemonicPage extends ConsumerStatefulWidget {
const ImportMnemonicPage({super.key});
@override
ConsumerState<ImportMnemonicPage> createState() => _ImportMnemonicPageState();
}
class _ImportMnemonicPageState extends ConsumerState<ImportMnemonicPage> {
//
final TextEditingController _serialNumController = TextEditingController();
// 12
final List<TextEditingController> _controllers = List.generate(
12,
(_) => TextEditingController(),
);
// 12
final List<FocusNode> _focusNodes = List.generate(
12,
(_) => FocusNode(),
);
//
bool _isSubmitting = false;
//
String? _errorMessage;
@override
void dispose() {
_serialNumController.dispose();
for (final controller in _controllers) {
controller.dispose();
}
for (final node in _focusNodes) {
node.dispose();
}
super.dispose();
}
///
String get _mnemonic {
return _controllers
.map((c) => c.text.trim().toLowerCase())
.join(' ');
}
///
int? get _serialNum {
final text = _serialNumController.text.trim();
if (text.isEmpty) return null;
return int.tryParse(text);
}
///
bool get _isComplete {
final hasSerialNum = _serialNumController.text.trim().isNotEmpty;
final hasAllWords = _controllers.every((c) => c.text.trim().isNotEmpty);
return hasSerialNum && hasAllWords;
}
///
Future<void> _importAndRecover() async {
//
final serialNum = _serialNum;
if (serialNum == null) {
setState(() {
_errorMessage = '请输入有效的序列号';
});
return;
}
//
if (!_controllers.every((c) => c.text.trim().isNotEmpty)) {
setState(() {
_errorMessage = '请填写完整的12个助记词';
});
return;
}
setState(() {
_isSubmitting = true;
_errorMessage = null;
});
try {
final accountService = ref.read(accountServiceProvider);
// API
final response = await accountService.recoverByMnemonic(
serialNum,
_mnemonic,
);
if (!mounted) return;
//
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('账户恢复成功!序列号: ${response.userSerialNum}'),
backgroundColor: const Color(0xFF52C41A),
),
);
//
context.go(RoutePaths.ranking);
} catch (e) {
debugPrint('恢复账户失败: $e');
if (!mounted) return;
setState(() {
_errorMessage = e.toString().replaceAll('Exception: ', '');
});
} finally {
if (mounted) {
setState(() {
_isSubmitting = false;
});
}
}
}
///
void _goBack() {
context.pop();
}
///
void _onWordChanged(int index, String value) {
//
if (_errorMessage != null) {
setState(() {
_errorMessage = null;
});
}
//
if (value.contains(' ')) {
_handlePaste(index, value);
return;
}
//
setState(() {});
}
///
void _handlePaste(int startIndex, String value) {
final words = value.trim().split(RegExp(r'\s+'));
for (int i = 0; i < words.length && startIndex + i < 12; i++) {
_controllers[startIndex + i].text = words[i].toLowerCase();
}
//
final nextIndex = (startIndex + words.length).clamp(0, 11);
_focusNodes[nextIndex].requestFocus();
setState(() {});
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Container(
width: double.infinity,
height: double.infinity,
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Color(0xFFFFF7E6), //
Color(0xFFEAE0C8), //
],
),
),
child: SafeArea(
child: Column(
children: [
//
_buildAppBar(),
//
Expanded(
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 16),
//
_buildSerialNumInput(),
const SizedBox(height: 16),
//
const Text(
'助记词',
style: TextStyle(
fontSize: 14,
fontFamily: 'Inter',
fontWeight: FontWeight.w500,
color: Color(0xFF745D43),
),
),
const SizedBox(height: 8),
//
_buildMnemonicInputArea(),
const SizedBox(height: 12),
//
const Text(
'支持 KAVA / DST / BSC 链的钱包导入。请在私密环境中操作。',
style: TextStyle(
fontSize: 14,
fontFamily: 'Inter',
height: 1.5,
color: Color(0xFF745D43),
),
),
//
if (_errorMessage != null) ...[
const SizedBox(height: 12),
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFFFFE4E4),
borderRadius: BorderRadius.circular(8),
),
child: Text(
_errorMessage!,
style: const TextStyle(
fontSize: 14,
color: Color(0xFFD32F2F),
),
),
),
],
],
),
),
),
),
//
_buildFooter(),
],
),
),
),
);
}
///
Widget _buildAppBar() {
return Container(
height: 56,
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Stack(
alignment: Alignment.center,
children: [
//
Positioned(
left: 0,
child: GestureDetector(
onTap: _goBack,
child: Container(
width: 48,
height: 48,
alignment: Alignment.center,
child: const Icon(
Icons.arrow_back_ios,
color: Color(0xFF5D4037),
size: 20,
),
),
),
),
//
const Text(
'导入助记词',
style: TextStyle(
fontSize: 18,
fontFamily: 'Inter',
fontWeight: FontWeight.w700,
letterSpacing: -0.27,
color: Color(0xFF5D4037),
),
),
],
),
);
}
///
Widget _buildSerialNumInput() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'序列号',
style: TextStyle(
fontSize: 14,
fontFamily: 'Inter',
fontWeight: FontWeight.w500,
color: Color(0xFF745D43),
),
),
const SizedBox(height: 8),
Container(
height: 48,
decoration: BoxDecoration(
color: const Color(0xFFFFFDF8),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: const Color(0x338B5A2B),
width: 1,
),
),
child: TextField(
controller: _serialNumController,
style: const TextStyle(
fontSize: 16,
fontFamily: 'Inter',
color: Color(0xFF5D4037),
),
decoration: const InputDecoration(
border: InputBorder.none,
contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 12),
hintText: '请输入账户序列号',
hintStyle: TextStyle(
fontSize: 16,
color: Color(0x668B5A2B),
),
),
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
],
onChanged: (_) {
if (_errorMessage != null) {
setState(() {
_errorMessage = null;
});
}
setState(() {});
},
),
),
],
);
}
///
Widget _buildMnemonicInputArea() {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFFFFFDF8),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: const Color(0x338B5A2B),
width: 1,
),
),
child: Column(
children: [
// 43
for (int row = 0; row < 4; row++) ...[
if (row > 0) const SizedBox(height: 12),
Row(
children: [
for (int col = 0; col < 3; col++) ...[
if (col > 0) const SizedBox(width: 12),
Expanded(
child: _buildWordInput(row * 3 + col),
),
],
],
),
],
],
),
);
}
///
Widget _buildWordInput(int index) {
return Container(
height: 44,
decoration: BoxDecoration(
color: const Color(0xFFFFF7E6),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: const Color(0x338B5A2B),
width: 1,
),
),
child: Row(
children: [
//
Container(
width: 28,
alignment: Alignment.center,
child: Text(
'${index + 1}',
style: const TextStyle(
fontSize: 12,
fontFamily: 'Inter',
fontWeight: FontWeight.w500,
color: Color(0xFF8B5A2B),
),
),
),
//
Expanded(
child: TextField(
controller: _controllers[index],
focusNode: _focusNodes[index],
style: const TextStyle(
fontSize: 14,
fontFamily: 'Inter',
color: Color(0xFF5D4037),
),
decoration: const InputDecoration(
border: InputBorder.none,
contentPadding: EdgeInsets.symmetric(horizontal: 4, vertical: 10),
isDense: true,
),
textInputAction: index < 11 ? TextInputAction.next : TextInputAction.done,
autocorrect: false,
enableSuggestions: false,
onChanged: (value) => _onWordChanged(index, value),
onSubmitted: (_) {
if (index < 11) {
_focusNodes[index + 1].requestFocus();
} else {
//
if (_isComplete) {
_importAndRecover();
}
}
},
),
),
],
),
);
}
///
Widget _buildFooter() {
return Container(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
child: Column(
children: [
//
GestureDetector(
onTap: _isSubmitting ? null : _importAndRecover,
child: Opacity(
opacity: _isComplete && !_isSubmitting ? 1.0 : 0.5,
child: Container(
width: double.infinity,
height: 48,
decoration: BoxDecoration(
color: const Color(0xFF8B5A2B),
borderRadius: BorderRadius.circular(8),
),
child: Center(
child: _isSubmitting
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
)
: const Text(
'导入并恢复账号',
style: TextStyle(
fontSize: 16,
fontFamily: 'Inter',
fontWeight: FontWeight.w700,
letterSpacing: 0.24,
color: Color(0xB3FFFFFF),
),
),
),
),
),
),
const SizedBox(height: 12),
//
GestureDetector(
onTap: _goBack,
child: Container(
padding: const EdgeInsets.symmetric(vertical: 8),
child: const Text(
'返回创建账号',
style: TextStyle(
fontSize: 14,
fontFamily: 'Inter',
decoration: TextDecoration.underline,
color: Color(0xFF745D43),
),
),
),
),
],
),
);
}
}

View File

@ -181,8 +181,8 @@ class _OnboardingPageState extends ConsumerState<OnboardingPage> {
///
void _importMnemonic() {
// TODO:
debugPrint('导入助记词');
debugPrint('[OnboardingPage] _importMnemonic - 跳转到导入助记词页面');
context.push(RoutePaths.importMnemonic);
}
///

View File

@ -1,9 +1,8 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
///
/// Tab
/// 使
/// 使 LayoutBuilder
class BottomNavBar extends StatelessWidget {
final int currentIndex;
final Function(int) onTap;
@ -16,8 +15,12 @@ class BottomNavBar extends StatelessWidget {
@override
Widget build(BuildContext context) {
//
final bottomPadding = MediaQuery.of(context).padding.bottom;
return Container(
height: 65.h,
// +
height: 56 + bottomPadding,
decoration: const BoxDecoration(
color: Color(0xFFFFF5E6),
border: Border(
@ -27,10 +30,10 @@ class BottomNavBar extends StatelessWidget {
),
),
),
child: SafeArea(
top: false,
child: Padding(
//
padding: EdgeInsets.only(bottom: bottomPadding),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildNavItem(
index: 0,
@ -74,26 +77,29 @@ class BottomNavBar extends StatelessWidget {
child: GestureDetector(
onTap: () => onTap(index),
behavior: HitTestBehavior.opaque,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
isSelected ? activeIcon : icon,
size: 24.sp,
color: isSelected ? const Color(0xFFD4AF37) : const Color(0xFF8B5A2B),
),
SizedBox(height: 2.h),
Text(
label,
style: TextStyle(
fontSize: 12.sp,
fontFamily: 'Inter',
fontWeight: FontWeight.w500,
height: 1.33,
child: SizedBox(
height: 56,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
isSelected ? activeIcon : icon,
size: 24,
color: isSelected ? const Color(0xFFD4AF37) : const Color(0xFF8B5A2B),
),
),
],
const SizedBox(height: 2),
Text(
label,
style: TextStyle(
fontSize: 12,
fontFamily: 'Inter',
fontWeight: FontWeight.w500,
height: 1.33,
color: isSelected ? const Color(0xFFD4AF37) : const Color(0xFF8B5A2B),
),
),
],
),
),
),
);

View File

@ -119,16 +119,21 @@ class _SharePageState extends ConsumerState<SharePage> {
_buildAppBar(),
//
Expanded(
child: SingleChildScrollView(
child: Column(
children: [
const SizedBox(height: 100),
// QR
_buildQrCodeSection(),
const SizedBox(height: 40),
//
_buildLinkSection(),
],
child: Center(
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// QR
_buildQrCodeSection(),
const SizedBox(height: 32),
//
_buildLinkSection(),
],
),
),
),
),
),

View File

@ -8,6 +8,7 @@ import '../features/auth/presentation/pages/onboarding_page.dart';
import '../features/auth/presentation/pages/backup_mnemonic_page.dart';
import '../features/auth/presentation/pages/verify_mnemonic_page.dart';
import '../features/auth/presentation/pages/wallet_created_page.dart';
import '../features/auth/presentation/pages/import_mnemonic_page.dart';
import '../features/home/presentation/pages/home_shell_page.dart';
import '../features/ranking/presentation/pages/ranking_page.dart';
import '../features/mining/presentation/pages/mining_page.dart';
@ -109,6 +110,13 @@ final appRouterProvider = Provider<GoRouter>((ref) {
builder: (context, state) => const OnboardingPage(),
),
// Import Mnemonic ()
GoRoute(
path: RoutePaths.importMnemonic,
name: RouteNames.importMnemonic,
builder: (context, state) => const ImportMnemonicPage(),
),
// Backup Mnemonic ( - API )
GoRoute(
path: RoutePaths.backupMnemonic,

View File

@ -10,6 +10,7 @@ class RouteNames {
static const verifyMnemonic = 'verify-mnemonic';
static const walletCreated = 'wallet-created';
static const importWallet = 'import-wallet';
static const importMnemonic = 'import-mnemonic';
// Main tabs
static const ranking = 'ranking';

View File

@ -10,6 +10,7 @@ class RoutePaths {
static const verifyMnemonic = '/auth/verify-mnemonic';
static const walletCreated = '/auth/wallet-created';
static const importWallet = '/auth/import';
static const importMnemonic = '/auth/import-mnemonic';
// Main tabs
static const ranking = '/ranking';