feat: 切换邮箱认证并重构前后端启动与门禁

This commit is contained in:
qzl
2026-04-02 18:39:35 +08:00
parent 92cdfd9fca
commit 31594558eb
116 changed files with 5608 additions and 628 deletions
+1
View File
@@ -57,6 +57,7 @@ This file governs `apps/**` (Flutter). Keep rules strict, short, and reusable.
- User feedback: `Toast` / `AppBanner` only.
- Loading indicators: `AppLoadingIndicator` only.
- Form pages should default to keyboard-overlay behavior to avoid full-page layout jumps.
- `ToastType.info` should be minimized: do not show informational toast for normal success paths (e.g., login success). Prefer silent success unless user must take action.
## Interaction & Feedback (Must)
+21 -10
View File
@@ -1,16 +1,27 @@
# meeyao_qianwen
# eryao apps
A new Flutter project.
Flutter client for `觅爻签问`.
## Getting Started
## Debug startup with backend injection
This project is a starting point for a Flutter application.
This app supports injecting backend URL at startup (same pattern as social-app):
A few resources to get you started if this is your first Flutter project:
- Dart read path: `lib/core/config/env.dart`
- Injection key: `BACKEND_URL`
- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook)
### Direct command
For help getting started with Flutter development, view the
[online documentation](https://docs.flutter.dev/), which offers tutorials,
samples, guidance on mobile development, and a full API reference.
```bash
flutter run --dart-define=BACKEND_URL=http://192.168.1.100:5775
```
### Script command
```bash
./tool/run-dev.sh --backend-url http://192.168.1.100:5775
```
If `BACKEND_URL` is not provided, fallback is:
- Android emulator: `http://10.0.2.2:5775`
- Others: `http://localhost:5775`
+26 -6
View File
@@ -1,3 +1,5 @@
import java.util.Properties
plugins {
id("com.android.application")
id("kotlin-android")
@@ -6,7 +8,7 @@ plugins {
}
android {
namespace = "com.meeyao.meeyao_qianwen"
namespace = "com.meeyao.qianwen"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
@@ -20,8 +22,7 @@ android {
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.meeyao.meeyao_qianwen"
applicationId = "com.meeyao.qianwen"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
@@ -30,11 +31,30 @@ android {
versionName = flutter.versionName
}
val keystoreProperties = Properties()
val keystorePropertiesFile = rootProject.file("key.properties")
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(keystorePropertiesFile.inputStream())
}
signingConfigs {
create("release") {
if (keystorePropertiesFile.exists()) {
keyAlias = keystoreProperties["keyAlias"] as String
keyPassword = keystoreProperties["keyPassword"] as String
storeFile = file(keystoreProperties["storeFile"] as String)
storePassword = keystoreProperties["storePassword"] as String
}
}
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
signingConfig = if (keystorePropertiesFile.exists()) {
signingConfigs.getByName("release")
} else {
signingConfigs.getByName("debug")
}
}
}
}
@@ -1,8 +1,11 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:label="meeyao_qianwen"
android:label="@string/app_name"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
android:icon="@mipmap/ic_launcher"
android:allowBackup="true"
android:fullBackupContent="@xml/backup_rules"
android:dataExtractionRules="@xml/data_extraction_rules">
<activity
android:name=".MainActivity"
android:exported="true"
@@ -1,4 +1,4 @@
package com.meeyao.meeyao_qianwen
package com.meeyao.qianwen
import io.flutter.embedding.android.FlutterActivity
Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground>
<inset
android:drawable="@drawable/ic_launcher_foreground"
android:inset="16%" />
</foreground>
</adaptive-icon>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 544 B

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 B

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 721 B

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 38 KiB

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">MeiYao Divination</string>
</resources>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#FFFFFF</color>
</resources>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">觅爻签问</string>
</resources>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<full-backup-content>
<exclude domain="sharedpref" path="." />
</full-backup-content>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<data-extraction-rules>
<cloud-backup>
<exclude domain="sharedpref" path="." />
</cloud-backup>
<device-transfer>
<exclude domain="sharedpref" path="." />
</device-transfer>
</data-extraction-rules>
+4
View File
@@ -0,0 +1,4 @@
storePassword=your_store_password
keyPassword=your_key_password
keyAlias=upload
storeFile=release.jks
@@ -1,122 +1 @@
{
"images" : [
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@3x.png",
"scale" : "3x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@3x.png",
"scale" : "3x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@3x.png",
"scale" : "3x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@2x.png",
"scale" : "2x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@3x.png",
"scale" : "3x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@1x.png",
"scale" : "1x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@1x.png",
"scale" : "1x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@1x.png",
"scale" : "1x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@2x.png",
"scale" : "2x"
},
{
"size" : "83.5x83.5",
"idiom" : "ipad",
"filename" : "Icon-App-83.5x83.5@2x.png",
"scale" : "2x"
},
{
"size" : "1024x1024",
"idiom" : "ios-marketing",
"filename" : "Icon-App-1024x1024@1x.png",
"scale" : "1x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}
{"images":[{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@3x.png","scale":"3x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@3x.png","scale":"3x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@3x.png","scale":"3x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@1x.png","scale":"1x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@3x.png","scale":"3x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@1x.png","scale":"1x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@1x.png","scale":"1x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@1x.png","scale":"1x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@2x.png","scale":"2x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@1x.png","scale":"1x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@2x.png","scale":"2x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@1x.png","scale":"1x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@2x.png","scale":"2x"},{"size":"83.5x83.5","idiom":"ipad","filename":"Icon-App-83.5x83.5@2x.png","scale":"2x"},{"size":"1024x1024","idiom":"ios-marketing","filename":"Icon-App-1024x1024@1x.png","scale":"1x"}],"info":{"version":1,"author":"xcode"}}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 462 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 295 B

After

Width:  |  Height:  |  Size: 962 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 450 B

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 B

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 462 B

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 704 B

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 586 B

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 762 B

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 30 KiB

+125
View File
@@ -0,0 +1,125 @@
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import '../core/auth/session_store.dart';
import '../data/network/api_client.dart';
import '../data/storage/local_kv_store.dart';
import '../features/auth/data/apis/auth_api.dart';
import '../features/auth/data/repositories/auth_repository.dart';
import '../features/auth/presentation/bloc/auth_bloc.dart';
import '../features/auth/presentation/bloc/auth_state.dart';
import '../features/auth/presentation/screens/login_screen.dart';
import '../features/home/presentation/screens/home_screen.dart';
import '../l10n/app_localizations.dart';
import '../shared/widgets/app_loading_indicator.dart';
import 'app_theme.dart';
import 'di/injection.dart';
class EryaoApp extends StatefulWidget {
const EryaoApp({super.key});
@override
State<EryaoApp> createState() => _EryaoAppState();
}
class _EryaoAppState extends State<EryaoApp> {
final SessionStore _sessionStore = SessionStore(LocalKvStore());
late final AuthBloc _authBloc;
Locale _locale = const Locale('zh');
@override
void initState() {
super.initState();
final apiClient = ApiClient(
baseUrl: appDependencies.backendUrl,
tokenProvider: _sessionStore.getToken,
onUnauthorized: () {
return _authBloc.handleUnauthorized401();
},
);
final authApi = AuthApi(apiClient: apiClient);
final authRepository = AuthRepositoryImpl(
authApi: authApi,
sessionStore: _sessionStore,
);
_authBloc = AuthBloc(repository: authRepository);
_bootstrap();
}
@override
void dispose() {
_authBloc.dispose();
super.dispose();
}
Future<void> _bootstrap() async {
final localeCode = await _sessionStore.getLocaleCode();
if (mounted) {
setState(() {
_locale = localeCode == 'en' ? const Locale('en') : const Locale('zh');
});
}
await _authBloc.start();
}
Future<void> _handleLocaleChanged(Locale locale) async {
await _sessionStore.saveLocaleCode(locale.languageCode);
if (!mounted) {
return;
}
setState(() {
_locale = locale;
});
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _authBloc,
builder: (context, _) {
return MaterialApp(
debugShowCheckedModeBanner: false,
onGenerateTitle: (context) => AppLocalizations.of(context)!.appTitle,
locale: _locale,
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: AppLocalizations.supportedLocales,
theme: AppTheme.light(),
home: _buildHomeByAuthState(_authBloc.state),
);
},
);
}
Widget _buildHomeByAuthState(AuthState state) {
if (state.status == AuthStatus.initial ||
state.status == AuthStatus.loading) {
return const Scaffold(
body: Center(
child: AppLoadingIndicator(variant: AppLoadingVariant.surface),
),
);
}
if (state.status == AuthStatus.authenticated && state.user != null) {
return HomeScreen(
account: state.user!.email,
sessionStore: _sessionStore,
onLogout: _authBloc.logout,
);
}
return LoginScreen(
currentLocale: _locale,
onLocaleChanged: _handleLocaleChanged,
onRequestOtp: _authBloc.sendOtp,
onLoginWithOtp: (email, otp) {
return _authBloc.loginWithOtp(email: email, otp: otp);
},
);
}
}
+76
View File
@@ -0,0 +1,76 @@
import 'package:flutter/material.dart';
import '../shared/theme/app_color_palette.dart';
class AppTheme {
static const Color _primary = Color(0xFF673AB7);
static const Color _accent = Color(0xFF9C27B0);
static const Color _scaffold = Color(0xFFF8F8F8);
static const Color _textHigh = Color(0xFF333333);
static const Color _textMid = Color(0xFF666666);
static const Color _textLow = Color(0xFF999999);
static ThemeData light() {
const colorScheme = ColorScheme.light(
primary: _primary,
onPrimary: Color(0xFFFFFFFF),
secondary: _accent,
onSecondary: Color(0xFFFFFFFF),
surface: Color(0xFFFFFFFF),
onSurface: _textHigh,
error: Color(0xFFB00020),
onError: Color(0xFFFFFFFF),
outline: Color(0xFFDDDDDD),
surfaceContainerHighest: Color(0xFFF0E6FF),
surfaceContainerHigh: Color(0xFFF4F5F7),
surfaceContainerLow: Color(0xFFFAFAFA),
);
return ThemeData(
useMaterial3: true,
colorScheme: colorScheme,
scaffoldBackgroundColor: _scaffold,
textTheme: const TextTheme(
headlineMedium: TextStyle(
fontSize: 24,
fontWeight: FontWeight.w700,
color: _textHigh,
),
titleLarge: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w600,
color: _textHigh,
),
titleMedium: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: _textHigh,
),
bodyLarge: TextStyle(fontSize: 16, color: _textMid),
bodyMedium: TextStyle(fontSize: 14, color: _textMid),
bodySmall: TextStyle(fontSize: 12, color: _textLow),
),
extensions: const <ThemeExtension<dynamic>>[
AppColorPalette(
accentPurple: _accent,
historyGoldBg: Color(0xFFFFF8E1),
historyGoldText: Color(0xFFFFB300),
historyBlueBg: Color(0xFFE6F7FF),
historyBlueText: Color(0xFF1890FF),
historyGrayBg: Color(0xFFF5F5F5),
historyGrayText: Color(0xFF9E9E9E),
categoryCareerBg: Color(0xFFF0E6FF),
categoryCareerText: Color(0xFF673AB7),
categoryLoveBg: Color(0xFFFFF3E0),
categoryLoveText: Color(0xFFFF9800),
categoryMoneyBg: Color(0xFFE8F5E9),
categoryMoneyText: Color(0xFF4CAF50),
notificationDot: Color(0xFFE53935),
warning: Color(0xFFF57C00),
warningContainer: Color(0xFFFFF3E0),
onWarningContainer: Color(0xFF8A4B00),
),
],
);
}
}
+17
View File
@@ -0,0 +1,17 @@
import '../../core/config/env.dart';
class AppDependencies {
const AppDependencies({required this.backendUrl});
final String backendUrl;
}
AppDependencies? _appDependencies;
AppDependencies get appDependencies {
return _appDependencies ?? AppDependencies(backendUrl: Env.backendUrl);
}
Future<void> configureDependencies() async {
_appDependencies = AppDependencies(backendUrl: Env.backendUrl);
}
+69
View File
@@ -0,0 +1,69 @@
import '../../data/storage/local_kv_store.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
class SessionStore {
SessionStore(this._kvStore);
final LocalKvStore _kvStore;
final FlutterSecureStorage _secureStorage = const FlutterSecureStorage(
aOptions: AndroidOptions(encryptedSharedPreferences: true),
);
static const String _tokenKey = 'auth_token';
static const String _refreshTokenKey = 'auth_refresh_token';
static const String _emailKey = 'saved_email';
static const String _welcomeReadKey = 'has_seen_welcome_dialog';
static const String _localeKey = 'selected_locale';
Future<void> saveToken(String token) async {
await _secureStorage.write(key: _tokenKey, value: token);
}
Future<String?> getToken() async {
return _secureStorage.read(key: _tokenKey);
}
Future<void> clearToken() async {
await _secureStorage.delete(key: _tokenKey);
}
Future<void> saveRefreshToken(String refreshToken) async {
await _secureStorage.write(key: _refreshTokenKey, value: refreshToken);
}
Future<String?> getRefreshToken() async {
return _secureStorage.read(key: _refreshTokenKey);
}
Future<void> clearRefreshToken() async {
await _secureStorage.delete(key: _refreshTokenKey);
}
Future<void> saveEmail(String email) async {
await _secureStorage.write(key: _emailKey, value: email);
}
Future<String?> getEmail() async {
return _secureStorage.read(key: _emailKey);
}
Future<void> clearEmail() async {
await _secureStorage.delete(key: _emailKey);
}
Future<void> setWelcomeRead(bool value) async {
await _kvStore.setBool(_welcomeReadKey, value);
}
Future<bool> hasReadWelcome() async {
return _kvStore.getBool(_welcomeReadKey);
}
Future<void> saveLocaleCode(String localeCode) async {
await _kvStore.setString(_localeKey, localeCode);
}
Future<String?> getLocaleCode() async {
return _kvStore.getString(_localeKey);
}
}
+17
View File
@@ -0,0 +1,17 @@
import 'dart:io';
class Env {
static String get backendUrl {
final injected = const String.fromEnvironment('BACKEND_URL');
if (injected.isNotEmpty && injected != 'false') {
return injected;
}
if (Platform.isAndroid) {
return 'http://10.0.2.2:5775';
}
return 'http://localhost:5775';
}
static Future<void> init() async {}
}
+18
View File
@@ -0,0 +1,18 @@
import 'package:flutter/foundation.dart';
import 'logger.dart';
class AppErrorHandler {
final Logger _logger = getLogger('flutter.error');
void register() {
FlutterError.onError = (details) {
_logger.error(
message: 'FlutterError: ${details.exceptionAsString()}',
error: details.exceptionAsString(),
stackTrace: details.stack ?? StackTrace.current,
extra: {'context': 'FlutterError.onError'},
);
FlutterError.presentError(details);
};
}
}
+27
View File
@@ -0,0 +1,27 @@
import 'log_entry.dart';
enum LogOutput { console, file }
class LogConfig {
final LogLevel minLevel;
final LogOutput output;
final String logFileName;
final String logDir;
const LogConfig({
this.minLevel = LogLevel.debug,
this.output = LogOutput.console,
this.logFileName = 'app.log',
this.logDir = 'logs',
});
static LogConfig forDebug() =>
const LogConfig(minLevel: LogLevel.debug, output: LogOutput.console);
static LogConfig forRelease() => const LogConfig(
minLevel: LogLevel.warning,
output: LogOutput.file,
logFileName: 'app.log',
logDir: 'logs',
);
}
+78
View File
@@ -0,0 +1,78 @@
enum LogLevel { debug, info, warning, error }
class LogEntry {
final DateTime timestamp;
final LogLevel level;
final String message;
final String module;
final String? funcName;
final int? lineNo;
final String? errorType;
final String? errorMessage;
final String? stackTrace;
final Map<String, dynamic>? extra;
LogEntry({
required this.timestamp,
required this.level,
required this.message,
required this.module,
this.funcName,
this.lineNo,
this.errorType,
this.errorMessage,
this.stackTrace,
this.extra,
});
Map<String, dynamic> toJson() => {
'timestamp': timestamp.toIso8601String(),
'level': level.name,
'message': message,
'module': module,
if (funcName != null) 'func_name': funcName,
if (lineNo != null) 'line_no': lineNo,
if (errorType != null) 'error_type': errorType,
if (errorMessage != null) 'error_message': errorMessage,
if (stackTrace != null) 'stack_trace': stackTrace,
if (extra != null && extra!.isNotEmpty) 'extra': extra,
};
String toConsoleString() {
final ts = timestamp.toIso8601String();
final location = [
if (funcName != null) funcName,
if (lineNo != null) '@$lineNo',
].join('');
final locationStr = location.isNotEmpty ? ' [$location]' : '';
final errorStr = errorType != null ? ' [$errorType]' : '';
final errorMsgStr = errorMessage != null ? ' $errorMessage' : '';
final extraStr = extra != null && extra!.isNotEmpty ? ' $extra' : '';
return '$ts ${level.name.toUpperCase().padRight(7)} [$module$locationStr]$errorStr $message$errorMsgStr$extraStr';
}
String toFileString() {
final sb = StringBuffer();
sb.writeln('[$timestamp] ${level.name.toUpperCase()} [$module]');
if (funcName != null || lineNo != null) {
sb.write(' at ${funcName ?? ''}');
if (lineNo != null) sb.write(':$lineNo');
sb.writeln();
}
sb.writeln(' $message');
if (errorType != null) {
sb.writeln(' Error: $errorType');
}
if (errorMessage != null) {
sb.writeln(' ErrorMessage: $errorMessage');
}
if (stackTrace != null) {
sb.writeln(' StackTrace:');
sb.writeln(stackTrace);
}
if (extra != null && extra!.isNotEmpty) {
sb.writeln(' Extra: $extra');
}
return sb.toString();
}
}
@@ -0,0 +1,36 @@
import 'dart:io';
import 'package:path_provider/path_provider.dart';
class LogFileHandler {
File? _file;
IOSink? _sink;
Future<void> init(String logDir, String logFileName) async {
final dir = await getApplicationDocumentsDirectory();
final logPath = '${dir.path}/$logDir';
await Directory(logPath).create(recursive: true);
_file = File('$logPath/$logFileName');
_sink = _file!.openWrite(mode: FileMode.append);
}
void write(String content) {
_sink?.writeln(content);
}
Future<void> flush() async {
await _sink?.flush();
}
Future<void> close() async {
await _sink?.close();
_sink = null;
_file = null;
}
Future<List<String>> readAllLines() async {
if (_file == null || !await _file!.exists()) return [];
return await _file!.readAsLines();
}
String? get filePath => _file?.path;
}
+172
View File
@@ -0,0 +1,172 @@
import 'package:flutter/foundation.dart';
import 'log_config.dart';
import 'log_entry.dart';
import 'log_file_handler.dart';
class LogService {
final LogConfig _config;
LogFileHandler? _fileHandler;
final _buffer = <String>[];
static const _maxBufferSize = 50;
LogService._({required LogConfig config}) : _config = config;
static Future<LogService> create({LogConfig? config}) async {
final isRelease = kReleaseMode;
final effectiveConfig =
config ?? (isRelease ? LogConfig.forRelease() : LogConfig.forDebug());
final service = LogService._(config: effectiveConfig);
if (effectiveConfig.output == LogOutput.file) {
service._fileHandler = LogFileHandler();
await service._fileHandler!.init(
effectiveConfig.logDir,
effectiveConfig.logFileName,
);
}
return service;
}
String? get logFilePath => _fileHandler?.filePath;
void _log(LogEntry entry) {
if (entry.level.index < _config.minLevel.index) return;
if (_config.output == LogOutput.console) {
debugPrint(entry.toConsoleString());
if (entry.stackTrace != null) {
debugPrint(entry.stackTrace!);
}
} else {
_buffer.add(entry.toFileString());
if (_buffer.length >= _maxBufferSize) {
_flushBuffer();
}
}
}
void _flushBuffer() {
for (final line in _buffer) {
_fileHandler?.write(line);
}
_buffer.clear();
_fileHandler?.flush();
}
(String?, int?) _extractLocation(StackTrace stackTrace) {
final frames = stackTrace.toString().split('\n');
for (final frame in frames) {
if (frame.contains('.dart')) {
final match = RegExp(
r'#\d+\s+(.+?)\s+\((.+?):(\d+)\)',
).firstMatch(frame);
if (match != null) {
return (match.group(1), int.tryParse(match.group(3) ?? ''));
}
}
}
return (null, null);
}
void debug({
required String message,
required String module,
Map<String, dynamic>? extra,
StackTrace? stackTrace,
}) {
final trace = stackTrace ?? StackTrace.current;
final (funcName, lineNo) = _extractLocation(trace);
_log(
LogEntry(
timestamp: DateTime.now(),
level: LogLevel.debug,
message: message,
module: module,
funcName: funcName,
lineNo: lineNo,
extra: extra,
stackTrace: trace.toString(),
),
);
}
void info({
required String message,
required String module,
Map<String, dynamic>? extra,
StackTrace? stackTrace,
}) {
final trace = stackTrace ?? StackTrace.current;
final (funcName, lineNo) = _extractLocation(trace);
_log(
LogEntry(
timestamp: DateTime.now(),
level: LogLevel.info,
message: message,
module: module,
funcName: funcName,
lineNo: lineNo,
extra: extra,
stackTrace: trace.toString(),
),
);
}
void warning({
required String message,
required String module,
Map<String, dynamic>? extra,
StackTrace? stackTrace,
}) {
final trace = stackTrace ?? StackTrace.current;
final (funcName, lineNo) = _extractLocation(trace);
_log(
LogEntry(
timestamp: DateTime.now(),
level: LogLevel.warning,
message: message,
module: module,
funcName: funcName,
lineNo: lineNo,
extra: extra,
stackTrace: trace.toString(),
),
);
}
void error({
required String message,
required Object error,
required StackTrace stackTrace,
required String module,
Map<String, dynamic>? extra,
}) {
final (funcName, lineNo) = _extractLocation(stackTrace);
_log(
LogEntry(
timestamp: DateTime.now(),
level: LogLevel.error,
message: message,
module: module,
funcName: funcName,
lineNo: lineNo,
errorType: error.runtimeType.toString(),
errorMessage: error.toString(),
stackTrace: stackTrace.toString(),
extra: extra,
),
);
}
void flush() {
_flushBuffer();
_fileHandler?.flush();
}
Future<List<String>> readLogs() async {
return await _fileHandler?.readAllLines() ?? [];
}
}
+94
View File
@@ -0,0 +1,94 @@
import 'package:flutter/foundation.dart';
import 'log_entry.dart';
import 'log_service.dart';
LogService? _globalLogService;
class Logger {
final String module;
final LogService? _service;
final bool _isNoOp;
Logger(this.module, this._service) : _isNoOp = _service == null;
factory Logger.get(String module) {
return Logger(module, _globalLogService);
}
static void setLogService(LogService service) {
_globalLogService = service;
}
void debug({
required String message,
Map<String, dynamic>? extra,
StackTrace? stackTrace,
}) {
if (_isNoOp) return;
_service!.debug(
message: message,
module: module,
extra: extra ?? {},
stackTrace: stackTrace,
);
}
void info({
required String message,
Map<String, dynamic>? extra,
StackTrace? stackTrace,
}) {
if (_isNoOp) return;
_service!.info(
message: message,
module: module,
extra: extra ?? {},
stackTrace: stackTrace,
);
}
void warning({
required String message,
Map<String, dynamic>? extra,
StackTrace? stackTrace,
}) {
if (_isNoOp) return;
_service!.warning(
message: message,
module: module,
extra: extra ?? {},
stackTrace: stackTrace,
);
}
void error({
required String message,
required Object error,
required StackTrace stackTrace,
Map<String, dynamic>? extra,
}) {
final entry = LogEntry(
timestamp: DateTime.now(),
level: LogLevel.error,
message: message,
module: module,
errorType: error.runtimeType.toString(),
errorMessage: error.toString(),
stackTrace: stackTrace.toString(),
extra: extra,
);
if (_isNoOp) {
debugPrint(entry.toConsoleString());
return;
}
_service!.error(
message: message,
error: error,
stackTrace: stackTrace,
module: module,
extra: extra,
);
}
}
Logger getLogger(String module) => Logger.get(module);
+22
View File
@@ -0,0 +1,22 @@
class ApiProblem implements Exception {
ApiProblem({
required this.status,
required this.title,
required this.detail,
this.code,
});
final int status;
final String title;
final String detail;
final String? code;
String toUserMessage() {
return 'Request failed';
}
@override
String toString() {
return toUserMessage();
}
}
@@ -0,0 +1,22 @@
import '../../l10n/app_localizations.dart';
import 'api_problem.dart';
String mapApiProblemToMessage(ApiProblem problem, AppLocalizations l10n) {
switch (problem.code) {
case 'AUTH_TOO_MANY_REQUESTS':
return l10n.errorTooManyRequests;
case 'AUTH_VERIFICATION_CODE_INVALID':
return l10n.errorInvalidVerificationCode;
case 'AUTH_REFRESH_TOKEN_INVALID':
return l10n.errorSessionExpired;
case 'AUTH_SERVICE_UNAVAILABLE':
return l10n.errorServiceUnavailable;
default:
break;
}
if (problem.status >= 500) {
return l10n.errorServerGeneric;
}
return l10n.errorRequestGeneric;
}
+112
View File
@@ -0,0 +1,112 @@
import 'package:dio/dio.dart';
import '../../core/logging/logger.dart';
import '../../core/network/api_problem.dart';
class ApiClient {
ApiClient({
required String baseUrl,
Future<String?> Function()? tokenProvider,
Future<void> Function()? onUnauthorized,
}) : _dio = Dio(
BaseOptions(
baseUrl: baseUrl,
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 15),
headers: const {'Content-Type': 'application/json'},
),
) {
if (tokenProvider != null) {
_dio.interceptors.add(
InterceptorsWrapper(
onRequest: (options, handler) async {
final token = await tokenProvider();
if (token != null && token.isNotEmpty) {
options.headers['Authorization'] = 'Bearer $token';
}
handler.next(options);
},
onError: (error, handler) async {
final status = error.response?.statusCode;
final authHeader =
error.requestOptions.headers['Authorization'] as String?;
final hasAuthHeader = authHeader != null && authHeader.isNotEmpty;
if (status == 401 && hasAuthHeader && onUnauthorized != null) {
await onUnauthorized();
}
handler.next(error);
},
),
);
}
}
final Dio _dio;
final Logger _logger = getLogger('data.network.api_client');
Future<void> postNoContent(String path, {Map<String, dynamic>? data}) async {
try {
await _dio.post<void>(path, data: data);
} on DioException catch (error, stackTrace) {
_logger.error(
message: 'POST no-content failed',
error: error,
stackTrace: stackTrace,
);
throw _mapProblem(error);
}
}
Future<void> deleteNoContent(
String path, {
Map<String, dynamic>? data,
}) async {
try {
await _dio.delete<void>(path, data: data);
} on DioException catch (error, stackTrace) {
_logger.error(
message: 'DELETE no-content failed',
error: error,
stackTrace: stackTrace,
);
throw _mapProblem(error);
}
}
Future<Map<String, dynamic>> postJson(
String path, {
Map<String, dynamic>? data,
}) async {
try {
final response = await _dio.post<Map<String, dynamic>>(path, data: data);
return response.data ?? <String, dynamic>{};
} on DioException catch (error, stackTrace) {
_logger.error(
message: 'POST json failed',
error: error,
stackTrace: stackTrace,
);
throw _mapProblem(error);
}
}
ApiProblem _mapProblem(DioException error) {
final status = error.response?.statusCode ?? 500;
final data = error.response?.data;
if (data is Map<String, dynamic>) {
return ApiProblem(
status: status,
title: (data['title'] as String?) ?? 'Request failed',
detail: (data['detail'] as String?) ?? '',
code: data['code'] as String?,
);
}
return ApiProblem(
status: status,
title: 'Network error',
detail: error.message ?? 'Request failed',
);
}
}
+32
View File
@@ -0,0 +1,32 @@
import 'package:shared_preferences/shared_preferences.dart';
class LocalKvStore {
Future<SharedPreferences> get _prefs async {
return SharedPreferences.getInstance();
}
Future<void> setString(String key, String value) async {
final prefs = await _prefs;
await prefs.setString(key, value);
}
Future<String?> getString(String key) async {
final prefs = await _prefs;
return prefs.getString(key);
}
Future<void> setBool(String key, bool value) async {
final prefs = await _prefs;
await prefs.setBool(key, value);
}
Future<bool> getBool(String key, {bool fallback = false}) async {
final prefs = await _prefs;
return prefs.getBool(key) ?? fallback;
}
Future<void> remove(String key) async {
final prefs = await _prefs;
await prefs.remove(key);
}
}
@@ -0,0 +1,41 @@
import '../../../../data/network/api_client.dart';
import '../models/session_response.dart';
class AuthApi {
AuthApi({required ApiClient apiClient}) : _apiClient = apiClient;
final ApiClient _apiClient;
Future<void> sendOtp({required String email}) async {
await _apiClient.postNoContent(
'/api/v1/auth/otp/send',
data: {'email': email},
);
}
Future<SessionResponse> createEmailSession({
required String email,
required String token,
}) async {
final json = await _apiClient.postJson(
'/api/v1/auth/email-session',
data: {'email': email, 'token': token},
);
return SessionResponse.fromJson(json);
}
Future<void> deleteSession({required String refreshToken}) async {
await _apiClient.deleteNoContent(
'/api/v1/auth/sessions',
data: {'refresh_token': refreshToken},
);
}
Future<SessionResponse> refreshSession({required String refreshToken}) async {
final json = await _apiClient.postJson(
'/api/v1/auth/sessions/refresh',
data: {'refresh_token': refreshToken},
);
return SessionResponse.fromJson(json);
}
}
@@ -0,0 +1,6 @@
class AuthUser {
const AuthUser({required this.id, required this.email});
final String id;
final String email;
}
@@ -0,0 +1,45 @@
class SessionResponse {
SessionResponse({
required this.accessToken,
required this.refreshToken,
required this.expiresIn,
required this.tokenType,
required this.userId,
required this.userEmail,
});
final String accessToken;
final String refreshToken;
final int expiresIn;
final String tokenType;
final String userId;
final String userEmail;
factory SessionResponse.fromJson(Map<String, dynamic> json) {
final user = (json['user'] as Map<String, dynamic>?) ?? <String, dynamic>{};
final accessToken = json['access_token'] as String?;
final refreshToken = json['refresh_token'] as String?;
final expiresIn = json['expires_in'] as int?;
final tokenType = json['token_type'] as String?;
final userId = user['id'] as String?;
final userEmail = user['email'] as String?;
if (accessToken == null ||
refreshToken == null ||
expiresIn == null ||
tokenType == null ||
userId == null ||
userEmail == null) {
throw const FormatException('Invalid session response payload');
}
return SessionResponse(
accessToken: accessToken,
refreshToken: refreshToken,
expiresIn: expiresIn,
tokenType: tokenType,
userId: userId,
userEmail: userEmail,
);
}
}
@@ -0,0 +1,86 @@
import '../../../../core/auth/session_store.dart';
import '../apis/auth_api.dart';
import '../models/auth_user.dart';
abstract class AuthRepository {
Future<void> sendOtp(String email);
Future<AuthUser> loginWithEmailOtp({
required String email,
required String otp,
});
Future<AuthUser?> recoverSession();
Future<void> logout();
Future<void> clearLocalSession();
}
class AuthRepositoryImpl implements AuthRepository {
AuthRepositoryImpl({
required AuthApi authApi,
required SessionStore sessionStore,
}) : _authApi = authApi,
_sessionStore = sessionStore;
final AuthApi _authApi;
final SessionStore _sessionStore;
@override
Future<void> sendOtp(String email) async {
await _authApi.sendOtp(email: email);
}
@override
Future<AuthUser> loginWithEmailOtp({
required String email,
required String otp,
}) async {
final session = await _authApi.createEmailSession(email: email, token: otp);
await _sessionStore.saveToken(session.accessToken);
await _sessionStore.saveRefreshToken(session.refreshToken);
await _sessionStore.saveEmail(email);
return AuthUser(id: session.userId, email: email);
}
@override
Future<AuthUser?> recoverSession() async {
final refreshToken = await _sessionStore.getRefreshToken();
if (refreshToken == null || refreshToken.isEmpty) {
return null;
}
final session = await _authApi.refreshSession(refreshToken: refreshToken);
await _sessionStore.saveToken(session.accessToken);
await _sessionStore.saveRefreshToken(session.refreshToken);
final savedEmail = await _sessionStore.getEmail();
final email = savedEmail?.isNotEmpty == true
? savedEmail!
: session.userEmail;
if (email.isNotEmpty) {
await _sessionStore.saveEmail(email);
}
return AuthUser(id: session.userId, email: email);
}
@override
Future<void> logout() async {
final refreshToken = await _sessionStore.getRefreshToken();
try {
if (refreshToken != null && refreshToken.isNotEmpty) {
await _authApi.deleteSession(refreshToken: refreshToken);
}
} finally {
await clearLocalSession();
}
}
@override
Future<void> clearLocalSession() async {
await _sessionStore.clearToken();
await _sessionStore.clearRefreshToken();
await _sessionStore.clearEmail();
}
}
@@ -0,0 +1,98 @@
import 'package:flutter/foundation.dart';
import '../../../../core/logging/logger.dart';
import '../../data/repositories/auth_repository.dart';
import 'auth_state.dart';
class AuthBloc extends ChangeNotifier {
AuthBloc({required AuthRepository repository}) : _repository = repository;
final AuthRepository _repository;
final Logger _logger = getLogger('features.auth.bloc');
AuthState _state = AuthState.initial;
bool _handlingUnauthorized = false;
AuthState get state => _state;
Future<void> start() async {
_state = _state.copyWith(status: AuthStatus.loading, errorMessage: null);
notifyListeners();
try {
final user = await _repository.recoverSession();
if (user == null) {
_state = const AuthState(status: AuthStatus.unauthenticated);
} else {
_state = AuthState(status: AuthStatus.authenticated, user: user);
}
notifyListeners();
} catch (error, stackTrace) {
_logger.error(
message: 'Session recovery failed',
error: error,
stackTrace: stackTrace,
);
await _repository.clearLocalSession();
_state = AuthState(
status: AuthStatus.unauthenticated,
errorMessage: _toSafeMessage(error),
);
notifyListeners();
}
}
Future<void> sendOtp(String email) async {
await _repository.sendOtp(email);
}
Future<void> loginWithOtp({
required String email,
required String otp,
}) async {
final user = await _repository.loginWithEmailOtp(email: email, otp: otp);
_logger.info(message: 'User logged in', extra: {'user_id': user.id});
_state = AuthState(status: AuthStatus.authenticated, user: user);
notifyListeners();
}
Future<void> logout() async {
Object? caughtError;
StackTrace? caughtStackTrace;
try {
await _repository.logout();
} catch (error, stackTrace) {
caughtError = error;
caughtStackTrace = stackTrace;
_logger.error(
message: 'User logout failed',
error: error,
stackTrace: stackTrace,
);
}
_logger.info(message: 'User logged out');
_state = const AuthState(status: AuthStatus.unauthenticated);
notifyListeners();
if (caughtError != null) {
Error.throwWithStackTrace(caughtError, caughtStackTrace!);
}
}
Future<void> handleUnauthorized401() async {
if (_handlingUnauthorized) {
return;
}
_handlingUnauthorized = true;
try {
await _repository.clearLocalSession();
_logger.warning(message: 'Session invalidated by 401 callback');
_state = const AuthState(status: AuthStatus.unauthenticated);
notifyListeners();
} finally {
_handlingUnauthorized = false;
}
}
String _toSafeMessage(Object error) {
return 'Request failed, please try again';
}
}
@@ -0,0 +1,25 @@
import '../../data/models/auth_user.dart';
enum AuthStatus { initial, loading, authenticated, unauthenticated }
class AuthState {
const AuthState({required this.status, this.user, this.errorMessage});
final AuthStatus status;
final AuthUser? user;
final String? errorMessage;
AuthState copyWith({
AuthStatus? status,
AuthUser? user,
String? errorMessage,
}) {
return AuthState(
status: status ?? this.status,
user: user ?? this.user,
errorMessage: errorMessage,
);
}
static const AuthState initial = AuthState(status: AuthStatus.initial);
}
@@ -0,0 +1,388 @@
import 'dart:async';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import '../../../../core/logging/logger.dart';
import '../../../../core/network/api_problem.dart';
import '../../../../core/network/api_problem_mapper.dart';
import '../../../../l10n/app_localizations.dart';
import '../../../../shared/theme/design_tokens.dart';
import '../../../../shared/widgets/toast/toast.dart';
import '../../../../shared/widgets/toast/toast_type.dart';
class LoginScreen extends StatefulWidget {
const LoginScreen({
super.key,
required this.onRequestOtp,
required this.onLoginWithOtp,
required this.onLocaleChanged,
required this.currentLocale,
});
final Future<void> Function(String email) onRequestOtp;
final Future<void> Function(String email, String otp) onLoginWithOtp;
final ValueChanged<Locale> onLocaleChanged;
final Locale currentLocale;
@override
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
final Logger _logger = getLogger('features.auth.login');
final TextEditingController _emailController = TextEditingController();
final TextEditingController _codeController = TextEditingController();
Timer? _timer;
int _countdown = 0;
bool _isSending = false;
bool _agreementChecked = false;
bool get _isValidEmail {
return RegExp(
r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$',
).hasMatch(_emailController.text.trim());
}
@override
void dispose() {
_timer?.cancel();
_emailController.dispose();
_codeController.dispose();
super.dispose();
}
void _showMessage(String message) {
Toast.show(context, message, type: ToastType.info);
}
Future<void> _sendCode() async {
final l10n = AppLocalizations.of(context)!;
if (!_isValidEmail) {
_showMessage(l10n.invalidEmail);
return;
}
if (_countdown > 0 || _isSending) {
return;
}
setState(() {
_isSending = true;
});
try {
await widget.onRequestOtp(_emailController.text.trim());
if (!mounted) {
return;
}
setState(() {
_isSending = false;
_countdown = 60;
});
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
if (!mounted) {
timer.cancel();
return;
}
if (_countdown <= 1) {
timer.cancel();
setState(() {
_countdown = 0;
});
} else {
setState(() {
_countdown -= 1;
});
}
});
} catch (error, stackTrace) {
_logger.error(
message: 'Send OTP failed',
error: error,
stackTrace: stackTrace,
);
if (!mounted) {
return;
}
setState(() {
_isSending = false;
});
_showMessage(_safeErrorMessage(error));
}
}
Future<void> _login() async {
final l10n = AppLocalizations.of(context)!;
if (!_isValidEmail) {
_showMessage(l10n.invalidEmail);
return;
}
if (_codeController.text.length != 6) {
_showMessage(l10n.invalidCode);
return;
}
if (!_agreementChecked) {
_showMessage(l10n.agreementRequired);
return;
}
try {
await widget.onLoginWithOtp(
_emailController.text.trim(),
_codeController.text,
);
if (!mounted) {
return;
}
} catch (error, stackTrace) {
_logger.error(
message: 'Login with OTP failed',
error: error,
stackTrace: stackTrace,
);
_showMessage(_safeErrorMessage(error));
}
}
String _safeErrorMessage(Object error) {
final l10n = AppLocalizations.of(context)!;
if (error is ApiProblem) {
return mapApiProblemToMessage(error, l10n);
}
return l10n.errorRequestGeneric;
}
void _showPolicyDialog(String title, String content) {
showDialog<void>(
context: context,
builder: (context) {
return AlertDialog(
title: Text(title),
content: Text(content),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(AppLocalizations.of(context)!.dialogConfirm),
),
],
);
},
);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final colors = Theme.of(context).colorScheme;
final canLogin =
_isValidEmail && _codeController.text.length == 6 && _agreementChecked;
return Scaffold(
body: GestureDetector(
onTap: () => FocusScope.of(context).unfocus(),
child: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.xl,
vertical: AppSpacing.lg,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: AppSpacing.xxxl),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.welcomeLogin,
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: AppSpacing.sm),
Text(
l10n.loginSubtitleEmail,
style: Theme.of(context).textTheme.bodyLarge,
),
],
),
),
PopupMenuButton<Locale>(
icon: Icon(Icons.language, color: colors.primary),
onSelected: widget.onLocaleChanged,
itemBuilder: (context) => [
PopupMenuItem<Locale>(
value: const Locale('zh'),
child: Text(l10n.chinese),
),
PopupMenuItem<Locale>(
value: const Locale('en'),
child: Text(l10n.english),
),
],
),
],
),
const SizedBox(height: AppSpacing.xxl),
TextField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
onChanged: (_) => setState(() {}),
decoration: InputDecoration(
hintText: l10n.emailHint,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.sm),
),
),
),
const SizedBox(height: AppSpacing.lg),
Row(
children: [
Expanded(
child: TextField(
controller: _codeController,
keyboardType: TextInputType.number,
maxLength: 6,
onChanged: (_) => setState(() {}),
decoration: InputDecoration(
counterText: '',
hintText: l10n.codeHint,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.sm),
),
),
),
),
const SizedBox(width: AppSpacing.sm),
SizedBox(
width: 130,
height: 48,
child: FilledButton(
style: FilledButton.styleFrom(
backgroundColor: colors.surfaceContainerHighest,
foregroundColor: colors.primary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.sm),
),
),
onPressed: _sendCode,
child: Text(
_isSending
? l10n.sending
: _countdown > 0
? l10n.retryAfter(_countdown)
: l10n.sendCode,
),
),
),
],
),
const SizedBox(height: AppSpacing.xl),
SizedBox(
width: double.infinity,
child: FilledButton(
style: FilledButton.styleFrom(
backgroundColor: colors.primary,
foregroundColor: colors.onPrimary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.sm),
),
),
onPressed: canLogin ? _login : null,
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: AppSpacing.sm,
),
child: Text(
l10n.login,
style: const TextStyle(fontSize: 16),
),
),
),
),
const SizedBox(height: AppSpacing.md),
Center(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Checkbox(
value: _agreementChecked,
onChanged: (value) {
setState(() {
_agreementChecked = value ?? false;
});
},
),
Flexible(
child: RichText(
text: TextSpan(
style: Theme.of(context).textTheme.bodySmall,
children: [
TextSpan(text: l10n.agreementPrefix),
TextSpan(
text: l10n.privacyPolicy,
style: TextStyle(
color: colors.primary,
decoration: TextDecoration.underline,
),
recognizer: TapGestureRecognizer()
..onTap = () => _showPolicyDialog(
l10n.privacyPolicy,
l10n.privacyContent,
),
),
TextSpan(text: l10n.agreementSeparator),
TextSpan(
text: l10n.termsOfService,
style: TextStyle(
color: colors.primary,
decoration: TextDecoration.underline,
),
recognizer: TapGestureRecognizer()
..onTap = () => _showPolicyDialog(
l10n.termsOfService,
l10n.termsContent,
),
),
TextSpan(text: l10n.agreementAnd),
TextSpan(
text: l10n.disclaimer,
style: TextStyle(
color: colors.primary,
decoration: TextDecoration.underline,
),
recognizer: TapGestureRecognizer()
..onTap = () => _showPolicyDialog(
l10n.disclaimer,
l10n.disclaimerContent,
),
),
],
),
),
),
],
),
),
const Spacer(),
Center(
child: Text(
l10n.icp,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colors.primary,
fontWeight: FontWeight.w700,
),
),
),
const SizedBox(height: AppSpacing.sm),
],
),
),
),
),
);
}
}
@@ -0,0 +1,559 @@
import 'package:flutter/material.dart';
import '../../../../core/auth/session_store.dart';
import '../../../../l10n/app_localizations.dart';
import '../../../../shared/theme/app_color_palette.dart';
import '../../../../shared/theme/design_tokens.dart';
import '../../../../shared/widgets/bottom_nav_bar.dart';
import '../../../../shared/widgets/toast/toast.dart';
import '../../../../shared/widgets/toast/toast_type.dart';
class HomeScreen extends StatefulWidget {
const HomeScreen({
super.key,
required this.account,
required this.sessionStore,
required this.onLogout,
});
final String account;
final SessionStore sessionStore;
final Future<void> Function() onLogout;
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
bool _showNotificationDot = true;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
_tryShowWelcomeDialog();
});
}
Future<void> _tryShowWelcomeDialog() async {
final hasRead = await widget.sessionStore.hasReadWelcome();
if (hasRead || !mounted) {
return;
}
await showDialog<void>(
context: context,
barrierDismissible: false,
builder: (context) {
return _WelcomeDialog(
onDone: () async {
await widget.sessionStore.setWelcomeRead(true);
},
);
},
);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final colors = Theme.of(context).colorScheme;
final palette = Theme.of(context).extension<AppColorPalette>()!;
final historyItems = [
_HistoryItemData(
question: l10n.historyQuestion1,
category: _HistoryCategory.career,
guaName: l10n.guaName1,
sign: _HistorySign.good,
),
_HistoryItemData(
question: l10n.historyQuestion2,
category: _HistoryCategory.love,
guaName: l10n.guaName2,
sign: _HistorySign.normal,
),
_HistoryItemData(
question: l10n.historyQuestion3,
category: _HistoryCategory.money,
guaName: l10n.guaName3,
sign: _HistorySign.best,
),
];
return Scaffold(
backgroundColor: colors.surfaceContainerLow,
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.only(
top: AppSpacing.lg,
bottom: AppSpacing.lg,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.lg),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
l10n.helloUser(
widget.account.isEmpty
? l10n.defaultUserName
: widget.account,
),
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(color: colors.primary),
),
Stack(
children: [
IconButton(
onPressed: () {
setState(() {
_showNotificationDot = false;
});
_showSnack(context, l10n.featurePending);
},
icon: Icon(
Icons.notifications,
color: colors.primary,
size: 28,
),
tooltip: l10n.notify,
),
if (_showNotificationDot)
Positioned(
right: AppSpacing.sm,
top: AppSpacing.sm,
child: Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: palette.notificationDot,
shape: BoxShape.circle,
),
),
),
],
),
],
),
),
const SizedBox(height: AppSpacing.xl),
Padding(
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.lg),
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(AppSpacing.xl),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppRadius.lg),
gradient: LinearGradient(
colors: [colors.primary, palette.accentPurple],
),
),
child: Column(
children: [
Icon(
Icons.auto_awesome,
color: colors.onPrimary,
size: 48,
),
const SizedBox(height: AppSpacing.lg),
Text(
l10n.startJourney,
style: Theme.of(context).textTheme.titleMedium
?.copyWith(
color: colors.onPrimary,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: AppSpacing.sm),
Text(
l10n.journeySubtitle,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colors.onPrimary,
),
),
const SizedBox(height: AppSpacing.lg),
FilledButton(
style: FilledButton.styleFrom(
backgroundColor: colors.surface,
foregroundColor: colors.primary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.sm),
),
),
onPressed: _onStartDivination,
child: Text(l10n.startNow),
),
],
),
),
),
const SizedBox(height: AppSpacing.xl),
Padding(
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.lg),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
l10n.historyTitle,
style: Theme.of(context).textTheme.titleMedium,
),
TextButton(
onPressed: () => _showSnack(context, l10n.featurePending),
child: Text(l10n.more),
),
],
),
),
const SizedBox(height: AppSpacing.md),
if (historyItems.isEmpty)
SizedBox(
width: double.infinity,
height: 200,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
l10n.noRecords,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: AppSpacing.sm),
Text(l10n.noRecordsSubtitle),
],
),
)
else
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: historyItems.map((item) {
return Padding(
padding: const EdgeInsets.only(bottom: AppSpacing.md),
child: _HistoryCard(item: item),
);
}).toList(),
),
],
),
),
),
bottomNavigationBar: BottomNavBar(
currentTab: MainTab.home,
onTabChange: (_) {},
onLogoTap: _onStartDivination,
),
);
}
void _onStartDivination() {
final l10n = AppLocalizations.of(context)!;
_showSnack(context, l10n.featurePending);
}
void _showSnack(BuildContext context, String message) {
Toast.show(context, message, type: ToastType.info);
}
}
class _HistoryCard extends StatelessWidget {
const _HistoryCard({required this.item});
final _HistoryItemData item;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final colors = Theme.of(context).colorScheme;
final palette = Theme.of(context).extension<AppColorPalette>()!;
final categoryLabel = switch (item.category) {
_HistoryCategory.career => l10n.categoryCareer,
_HistoryCategory.love => l10n.categoryLove,
_HistoryCategory.money => l10n.categoryMoney,
};
final categoryStyle = switch (item.category) {
_HistoryCategory.career => (
palette.categoryCareerBg,
palette.categoryCareerText,
),
_HistoryCategory.love => (
palette.categoryLoveBg,
palette.categoryLoveText,
),
_HistoryCategory.money => (
palette.categoryMoneyBg,
palette.categoryMoneyText,
),
};
final signLabel = switch (item.sign) {
_HistorySign.best => l10n.signBest,
_HistorySign.good => l10n.signGood,
_HistorySign.normal => l10n.signNormal,
};
final signStyle = switch (item.sign) {
_HistorySign.best => (palette.historyGoldBg, palette.historyGoldText),
_HistorySign.good => (colors.surfaceContainerHighest, colors.primary),
_HistorySign.normal => (palette.historyGrayBg, palette.historyGrayText),
};
return Card(
margin: EdgeInsets.zero,
color: colors.surface,
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.md),
),
child: Padding(
padding: const EdgeInsets.all(AppSpacing.lg),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.question,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: AppSpacing.sm),
Wrap(
spacing: AppSpacing.sm,
runSpacing: AppSpacing.sm,
children: [
_Tag(
label: categoryLabel,
background: categoryStyle.$1,
foreground: categoryStyle.$2,
),
_Tag(
label: item.guaName,
background: palette.historyBlueBg,
foreground: palette.historyBlueText,
),
_Tag(
label: signLabel,
background: signStyle.$1,
foreground: signStyle.$2,
),
],
),
],
),
),
);
}
}
class _Tag extends StatelessWidget {
const _Tag({
required this.label,
required this.background,
required this.foreground,
});
final String label;
final Color background;
final Color foreground;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.sm,
vertical: AppSpacing.xs,
),
decoration: BoxDecoration(
color: background,
borderRadius: BorderRadius.circular(AppRadius.sm),
),
child: Text(
label,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: foreground),
),
);
}
}
class _WelcomeDialog extends StatefulWidget {
const _WelcomeDialog({required this.onDone});
final Future<void> Function() onDone;
@override
State<_WelcomeDialog> createState() => _WelcomeDialogState();
}
class _WelcomeDialogState extends State<_WelcomeDialog> {
final ScrollController _scrollController = ScrollController();
bool _hasScrolledToBottom = false;
@override
void initState() {
super.initState();
_scrollController.addListener(_handleScroll);
WidgetsBinding.instance.addPostFrameCallback((_) {
_syncScrollState();
});
}
@override
void dispose() {
_scrollController.removeListener(_handleScroll);
_scrollController.dispose();
super.dispose();
}
void _handleScroll() {
_syncScrollState();
}
void _syncScrollState() {
if (!_scrollController.hasClients) {
return;
}
final max = _scrollController.position.maxScrollExtent;
final current = _scrollController.offset;
final canReadAll = max <= AppSpacing.xs || current >= max - AppSpacing.md;
if (_hasScrolledToBottom == canReadAll) {
return;
}
setState(() {
_hasScrolledToBottom = canReadAll;
});
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final colors = Theme.of(context).colorScheme;
final palette = Theme.of(context).extension<AppColorPalette>()!;
return Dialog(
insetPadding: const EdgeInsets.symmetric(
horizontal: AppSpacing.lg,
vertical: AppSpacing.xl,
),
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 620),
child: Padding(
padding: const EdgeInsets.all(AppSpacing.xl),
child: Column(
children: [
Text(
l10n.welcomeDialogTitle,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: colors.primary,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: AppSpacing.lg),
Expanded(
child: SingleChildScrollView(
controller: _scrollController,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.welcomeParagraph1,
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: AppSpacing.md),
Text(
l10n.welcomeParagraph2,
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: AppSpacing.md),
Text(
l10n.welcomeParagraph3,
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: AppSpacing.lg),
Text(
l10n.warningTitle,
style: Theme.of(context).textTheme.titleMedium
?.copyWith(color: palette.warning),
),
const SizedBox(height: AppSpacing.xs),
Text(
l10n.warningBody,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: palette.warning,
),
),
],
),
),
),
const SizedBox(height: AppSpacing.md),
if (!_hasScrolledToBottom)
Padding(
padding: const EdgeInsets.only(bottom: AppSpacing.sm),
child: Text(
l10n.scrollHint,
style: Theme.of(context).textTheme.bodySmall,
),
),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: _hasScrolledToBottom
? () async {
await widget.onDone();
if (!context.mounted) {
return;
}
Navigator.of(context).pop();
}
: null,
style: FilledButton.styleFrom(
backgroundColor: _hasScrolledToBottom
? colors.primary
: colors.outline,
foregroundColor: colors.onPrimary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.sm),
),
),
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: AppSpacing.sm,
),
child: Text(
_hasScrolledToBottom
? l10n.understood
: l10n.readAllFirst,
),
),
),
),
],
),
),
),
);
}
}
enum _HistoryCategory { career, love, money }
enum _HistorySign { best, good, normal }
class _HistoryItemData {
const _HistoryItemData({
required this.question,
required this.category,
required this.guaName,
required this.sign,
});
final String question;
final _HistoryCategory category;
final String guaName;
final _HistorySign sign;
}
+92
View File
@@ -0,0 +1,92 @@
{
"@@locale": "en",
"appTitle": "MeiYao Divination",
"welcomeLogin": "Welcome Back",
"loginSubtitle": "Sign in with your email",
"loginSubtitleEmail": "Sign in with your email",
"emailHint": "Enter email address",
"codeHint": "Enter verification code",
"sendCode": "Get Code",
"sending": "Sending...",
"retryAfter": "Retry in {seconds}s",
"@retryAfter": {
"placeholders": {
"seconds": {
"type": "int"
}
}
},
"login": "Login",
"agreementPrefix": "I have read and agree to ",
"privacyPolicy": "Privacy Policy",
"termsOfService": "Terms of Service",
"disclaimer": "Disclaimer",
"icp": "Yue ICP 2025428416-1A",
"invalidPhone": "Please enter a valid phone number",
"invalidEmail": "Please enter a valid email address",
"invalidCode": "Please enter a 6-digit code",
"agreementRequired": "Please accept the agreements first",
"codeSent": "Code sent successfully",
"loginSuccess": "Login success",
"helloUser": "Hi, {name}",
"@helloUser": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"startJourney": "Start Your Divination Journey",
"journeySubtitle": "Explore possibilities with AI",
"startNow": "Start Now",
"historyTitle": "History",
"more": "More",
"noRecords": "No records yet",
"noRecordsSubtitle": "You have not saved any records",
"homeTab": "Home",
"profileTab": "Me",
"notify": "Notifications",
"featurePending": "This feature is not connected yet",
"logout": "Logout",
"defaultUserName": "User",
"historyQuestion1": "Is this year a good time to change jobs?",
"historyQuestion2": "Can my relationship progress soon?",
"historyQuestion3": "What pace should I keep for investments this quarter?",
"guaName1": "Wuwang",
"guaName2": "Ge",
"guaName3": "Guan",
"welcomeDialogTitle": "Welcome to MeiYao Divination",
"welcomeParagraph1": "Welcome to MeiYao Divination, an AI-assisted platform for interpreting traditional Six-Line divination and exploring Chinese classic wisdom.",
"welcomeParagraph2": "Six-Line divination comes from the profound philosophy of the I Ching. It reflects how intention and timing are mapped into symbolic patterns.",
"welcomeParagraph3": "MeiYao Divination helps you look beyond narrow thinking, see opportunities and risks from a broader trend perspective, and make clearer decisions.",
"warningTitle": "Important Notice",
"warningBody": "All interpretations are AI-generated for entertainment only. Do not use them as professional advice for business, medical, or legal decisions.",
"scrollHint": "Scroll down to read all",
"understood": "I Understand",
"readAllFirst": "Please read all first",
"categoryCareer": "Career/Study",
"categoryLove": "Love/Marriage",
"categoryMoney": "Wealth/Investment",
"signBest": "Excellent",
"signGood": "Good",
"signNormal": "Moderate",
"language": "Language",
"english": "English",
"chinese": "Chinese",
"dialogConfirm": "OK",
"agreementSeparator": ", ",
"agreementAnd": " and ",
"privacyContent": "Placeholder content for privacy policy.",
"termsContent": "Placeholder content for terms of service.",
"disclaimerContent": "Placeholder content for disclaimer.",
"toastLabelInfo": "Info",
"toastLabelSuccess": "Success",
"toastLabelWarning": "Warning",
"toastLabelError": "Error",
"errorTooManyRequests": "Too many requests, please try again later",
"errorInvalidVerificationCode": "Invalid verification code",
"errorSessionExpired": "Session expired, please login again",
"errorServiceUnavailable": "Service unavailable, please try again later",
"errorServerGeneric": "Server error, please try again later",
"errorRequestGeneric": "Request failed, please try again"
}
+584
View File
@@ -0,0 +1,584 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:intl/intl.dart' as intl;
import 'app_localizations_en.dart';
import 'app_localizations_zh.dart';
// ignore_for_file: type=lint
/// Callers can lookup localized strings with an instance of AppLocalizations
/// returned by `AppLocalizations.of(context)`.
///
/// Applications need to include `AppLocalizations.delegate()` in their app's
/// `localizationDelegates` list, and the locales they support in the app's
/// `supportedLocales` list. For example:
///
/// ```dart
/// import 'l10n/app_localizations.dart';
///
/// return MaterialApp(
/// localizationsDelegates: AppLocalizations.localizationsDelegates,
/// supportedLocales: AppLocalizations.supportedLocales,
/// home: MyApplicationHome(),
/// );
/// ```
///
/// ## Update pubspec.yaml
///
/// Please make sure to update your pubspec.yaml to include the following
/// packages:
///
/// ```yaml
/// dependencies:
/// # Internationalization support.
/// flutter_localizations:
/// sdk: flutter
/// intl: any # Use the pinned version from flutter_localizations
///
/// # Rest of dependencies
/// ```
///
/// ## iOS Applications
///
/// iOS applications define key application metadata, including supported
/// locales, in an Info.plist file that is built into the application bundle.
/// To configure the locales supported by your app, youll need to edit this
/// file.
///
/// First, open your projects ios/Runner.xcworkspace Xcode workspace file.
/// Then, in the Project Navigator, open the Info.plist file under the Runner
/// projects Runner folder.
///
/// Next, select the Information Property List item, select Add Item from the
/// Editor menu, then select Localizations from the pop-up menu.
///
/// Select and expand the newly-created Localizations item then, for each
/// locale your application supports, add a new item and select the locale
/// you wish to add from the pop-up menu in the Value field. This list should
/// be consistent with the languages listed in the AppLocalizations.supportedLocales
/// property.
abstract class AppLocalizations {
AppLocalizations(String locale)
: localeName = intl.Intl.canonicalizedLocale(locale.toString());
final String localeName;
static AppLocalizations? of(BuildContext context) {
return Localizations.of<AppLocalizations>(context, AppLocalizations);
}
static const LocalizationsDelegate<AppLocalizations> delegate =
_AppLocalizationsDelegate();
/// A list of this localizations delegate along with the default localizations
/// delegates.
///
/// Returns a list of localizations delegates containing this delegate along with
/// GlobalMaterialLocalizations.delegate, GlobalCupertinoLocalizations.delegate,
/// and GlobalWidgetsLocalizations.delegate.
///
/// Additional delegates can be added by appending to this list in
/// MaterialApp. This list does not have to be used at all if a custom list
/// of delegates is preferred or required.
static const List<LocalizationsDelegate<dynamic>> localizationsDelegates =
<LocalizationsDelegate<dynamic>>[
delegate,
GlobalMaterialLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
];
/// A list of this localizations delegate's supported locales.
static const List<Locale> supportedLocales = <Locale>[
Locale('en'),
Locale('zh'),
];
/// No description provided for @appTitle.
///
/// In zh, this message translates to:
/// **'觅爻签问'**
String get appTitle;
/// No description provided for @welcomeLogin.
///
/// In zh, this message translates to:
/// **'欢迎登录'**
String get welcomeLogin;
/// No description provided for @loginSubtitle.
///
/// In zh, this message translates to:
/// **'请使用邮箱登录'**
String get loginSubtitle;
/// No description provided for @loginSubtitleEmail.
///
/// In zh, this message translates to:
/// **'请使用邮箱登录'**
String get loginSubtitleEmail;
/// No description provided for @emailHint.
///
/// In zh, this message translates to:
/// **'请输入邮箱地址'**
String get emailHint;
/// No description provided for @codeHint.
///
/// In zh, this message translates to:
/// **'请输入验证码'**
String get codeHint;
/// No description provided for @sendCode.
///
/// In zh, this message translates to:
/// **'获取验证码'**
String get sendCode;
/// No description provided for @sending.
///
/// In zh, this message translates to:
/// **'发送中...'**
String get sending;
/// No description provided for @retryAfter.
///
/// In zh, this message translates to:
/// **'{seconds}秒后重试'**
String retryAfter(int seconds);
/// No description provided for @login.
///
/// In zh, this message translates to:
/// **'登录'**
String get login;
/// No description provided for @agreementPrefix.
///
/// In zh, this message translates to:
/// **'我已阅读并同意'**
String get agreementPrefix;
/// No description provided for @privacyPolicy.
///
/// In zh, this message translates to:
/// **'隐私政策'**
String get privacyPolicy;
/// No description provided for @termsOfService.
///
/// In zh, this message translates to:
/// **'服务条款'**
String get termsOfService;
/// No description provided for @disclaimer.
///
/// In zh, this message translates to:
/// **'免责声明'**
String get disclaimer;
/// No description provided for @icp.
///
/// In zh, this message translates to:
/// **'粤ICP备2025428416号-1A'**
String get icp;
/// No description provided for @invalidPhone.
///
/// In zh, this message translates to:
/// **'请输入正确的手机号码'**
String get invalidPhone;
/// No description provided for @invalidEmail.
///
/// In zh, this message translates to:
/// **'请输入正确的邮箱地址'**
String get invalidEmail;
/// No description provided for @invalidCode.
///
/// In zh, this message translates to:
/// **'请输入6位验证码'**
String get invalidCode;
/// No description provided for @agreementRequired.
///
/// In zh, this message translates to:
/// **'请先勾选协议'**
String get agreementRequired;
/// No description provided for @codeSent.
///
/// In zh, this message translates to:
/// **'验证码已发送,请注意查收'**
String get codeSent;
/// No description provided for @loginSuccess.
///
/// In zh, this message translates to:
/// **'登录成功'**
String get loginSuccess;
/// No description provided for @helloUser.
///
/// In zh, this message translates to:
/// **'您好,{name}'**
String helloUser(String name);
/// No description provided for @startJourney.
///
/// In zh, this message translates to:
/// **'开始您的卦象之旅'**
String get startJourney;
/// No description provided for @journeySubtitle.
///
/// In zh, this message translates to:
/// **'借助AI智能,探索未来的可能'**
String get journeySubtitle;
/// No description provided for @startNow.
///
/// In zh, this message translates to:
/// **'立即起卦'**
String get startNow;
/// No description provided for @historyTitle.
///
/// In zh, this message translates to:
/// **'历史解卦'**
String get historyTitle;
/// No description provided for @more.
///
/// In zh, this message translates to:
/// **'更多'**
String get more;
/// No description provided for @noRecords.
///
/// In zh, this message translates to:
/// **'暂无记录'**
String get noRecords;
/// No description provided for @noRecordsSubtitle.
///
/// In zh, this message translates to:
/// **'您并没有保存任何卦象'**
String get noRecordsSubtitle;
/// No description provided for @homeTab.
///
/// In zh, this message translates to:
/// **'首页'**
String get homeTab;
/// No description provided for @profileTab.
///
/// In zh, this message translates to:
/// **'我的'**
String get profileTab;
/// No description provided for @notify.
///
/// In zh, this message translates to:
/// **'消息通知'**
String get notify;
/// No description provided for @featurePending.
///
/// In zh, this message translates to:
/// **'该功能暂未接入数据'**
String get featurePending;
/// No description provided for @logout.
///
/// In zh, this message translates to:
/// **'退出登录'**
String get logout;
/// No description provided for @defaultUserName.
///
/// In zh, this message translates to:
/// **'用户'**
String get defaultUserName;
/// No description provided for @historyQuestion1.
///
/// In zh, this message translates to:
/// **'今年转岗是否合适?'**
String get historyQuestion1;
/// No description provided for @historyQuestion2.
///
/// In zh, this message translates to:
/// **'最近感情是否能推进?'**
String get historyQuestion2;
/// No description provided for @historyQuestion3.
///
/// In zh, this message translates to:
/// **'本季度投资节奏如何?'**
String get historyQuestion3;
/// No description provided for @guaName1.
///
/// In zh, this message translates to:
/// **'天雷无妄'**
String get guaName1;
/// No description provided for @guaName2.
///
/// In zh, this message translates to:
/// **'泽火革'**
String get guaName2;
/// No description provided for @guaName3.
///
/// In zh, this message translates to:
/// **'风地观'**
String get guaName3;
/// No description provided for @welcomeDialogTitle.
///
/// In zh, this message translates to:
/// **'欢迎使用觅爻签问'**
String get welcomeDialogTitle;
/// No description provided for @welcomeParagraph1.
///
/// In zh, this message translates to:
/// **'你好,欢迎来到觅爻签问,这是一个借助于AI解读传统六爻卦象的平台,为用户了解中国传统易学文化提供一个窗口。'**
String get welcomeParagraph1;
/// No description provided for @welcomeParagraph2.
///
/// In zh, this message translates to:
/// **'六爻卦象源于《周易》深邃的哲学体系,是古人探索世界运行规律的一种独特方法。古人认为宇宙万物相互关联,在你起卦时,你的心念与时空信息会凝结成卦象的方式呈现出来。'**
String get welcomeParagraph2;
/// No description provided for @welcomeParagraph3.
///
/// In zh, this message translates to:
/// **'觅爻签问基于这样的思路,帮助你跳出局限思维,从全局和演变趋势看清矛盾、机会与风险,为判断和行动提供参考。'**
String get welcomeParagraph3;
/// No description provided for @warningTitle.
///
/// In zh, this message translates to:
/// **'特别提醒'**
String get warningTitle;
/// No description provided for @warningBody.
///
/// In zh, this message translates to:
/// **'卦象解读结果均由AI生成,仅供娱乐参考,切不可作为商业、医疗等专业领域的决策依据。理性看待卦象,自由掌握人生。'**
String get warningBody;
/// No description provided for @scrollHint.
///
/// In zh, this message translates to:
/// **'请向下滚动阅读全部内容'**
String get scrollHint;
/// No description provided for @understood.
///
/// In zh, this message translates to:
/// **'我已了解'**
String get understood;
/// No description provided for @readAllFirst.
///
/// In zh, this message translates to:
/// **'请先阅读完整内容'**
String get readAllFirst;
/// No description provided for @categoryCareer.
///
/// In zh, this message translates to:
/// **'事业学业'**
String get categoryCareer;
/// No description provided for @categoryLove.
///
/// In zh, this message translates to:
/// **'情感婚姻'**
String get categoryLove;
/// No description provided for @categoryMoney.
///
/// In zh, this message translates to:
/// **'财富投资'**
String get categoryMoney;
/// No description provided for @signBest.
///
/// In zh, this message translates to:
/// **'上上签'**
String get signBest;
/// No description provided for @signGood.
///
/// In zh, this message translates to:
/// **'中上签'**
String get signGood;
/// No description provided for @signNormal.
///
/// In zh, this message translates to:
/// **'中下签'**
String get signNormal;
/// No description provided for @language.
///
/// In zh, this message translates to:
/// **'语言'**
String get language;
/// No description provided for @english.
///
/// In zh, this message translates to:
/// **'英文'**
String get english;
/// No description provided for @chinese.
///
/// In zh, this message translates to:
/// **'中文'**
String get chinese;
/// No description provided for @dialogConfirm.
///
/// In zh, this message translates to:
/// **'确定'**
String get dialogConfirm;
/// No description provided for @agreementSeparator.
///
/// In zh, this message translates to:
/// **'、'**
String get agreementSeparator;
/// No description provided for @agreementAnd.
///
/// In zh, this message translates to:
/// **'和'**
String get agreementAnd;
/// No description provided for @privacyContent.
///
/// In zh, this message translates to:
/// **'隐私政策内容展示占位。'**
String get privacyContent;
/// No description provided for @termsContent.
///
/// In zh, this message translates to:
/// **'服务条款内容展示占位。'**
String get termsContent;
/// No description provided for @disclaimerContent.
///
/// In zh, this message translates to:
/// **'免责声明内容展示占位。'**
String get disclaimerContent;
/// No description provided for @toastLabelInfo.
///
/// In zh, this message translates to:
/// **'提示'**
String get toastLabelInfo;
/// No description provided for @toastLabelSuccess.
///
/// In zh, this message translates to:
/// **'成功'**
String get toastLabelSuccess;
/// No description provided for @toastLabelWarning.
///
/// In zh, this message translates to:
/// **'警告'**
String get toastLabelWarning;
/// No description provided for @toastLabelError.
///
/// In zh, this message translates to:
/// **'错误'**
String get toastLabelError;
/// No description provided for @errorTooManyRequests.
///
/// In zh, this message translates to:
/// **'请求过于频繁,请稍后重试'**
String get errorTooManyRequests;
/// No description provided for @errorInvalidVerificationCode.
///
/// In zh, this message translates to:
/// **'验证码错误'**
String get errorInvalidVerificationCode;
/// No description provided for @errorSessionExpired.
///
/// In zh, this message translates to:
/// **'登录已过期,请重新登录'**
String get errorSessionExpired;
/// No description provided for @errorServiceUnavailable.
///
/// In zh, this message translates to:
/// **'服务暂时不可用,请稍后重试'**
String get errorServiceUnavailable;
/// No description provided for @errorServerGeneric.
///
/// In zh, this message translates to:
/// **'服务异常,请稍后重试'**
String get errorServerGeneric;
/// No description provided for @errorRequestGeneric.
///
/// In zh, this message translates to:
/// **'请求失败,请稍后重试'**
String get errorRequestGeneric;
}
class _AppLocalizationsDelegate
extends LocalizationsDelegate<AppLocalizations> {
const _AppLocalizationsDelegate();
@override
Future<AppLocalizations> load(Locale locale) {
return SynchronousFuture<AppLocalizations>(lookupAppLocalizations(locale));
}
@override
bool isSupported(Locale locale) =>
<String>['en', 'zh'].contains(locale.languageCode);
@override
bool shouldReload(_AppLocalizationsDelegate old) => false;
}
AppLocalizations lookupAppLocalizations(Locale locale) {
// Lookup logic when only language code is specified.
switch (locale.languageCode) {
case 'en':
return AppLocalizationsEn();
case 'zh':
return AppLocalizationsZh();
}
throw FlutterError(
'AppLocalizations.delegate failed to load unsupported locale "$locale". This is likely '
'an issue with the localizations generation tool. Please file an issue '
'on GitHub with a reproducible sample app and the gen-l10n configuration '
'that was used.',
);
}
+246
View File
@@ -0,0 +1,246 @@
// ignore: unused_import
import 'package:intl/intl.dart' as intl;
import 'app_localizations.dart';
// ignore_for_file: type=lint
/// The translations for English (`en`).
class AppLocalizationsEn extends AppLocalizations {
AppLocalizationsEn([String locale = 'en']) : super(locale);
@override
String get appTitle => 'MeiYao Divination';
@override
String get welcomeLogin => 'Welcome Back';
@override
String get loginSubtitle => 'Sign in with your email';
@override
String get loginSubtitleEmail => 'Sign in with your email';
@override
String get emailHint => 'Enter email address';
@override
String get codeHint => 'Enter verification code';
@override
String get sendCode => 'Get Code';
@override
String get sending => 'Sending...';
@override
String retryAfter(int seconds) {
return 'Retry in ${seconds}s';
}
@override
String get login => 'Login';
@override
String get agreementPrefix => 'I have read and agree to ';
@override
String get privacyPolicy => 'Privacy Policy';
@override
String get termsOfService => 'Terms of Service';
@override
String get disclaimer => 'Disclaimer';
@override
String get icp => 'Yue ICP 2025428416-1A';
@override
String get invalidPhone => 'Please enter a valid phone number';
@override
String get invalidEmail => 'Please enter a valid email address';
@override
String get invalidCode => 'Please enter a 6-digit code';
@override
String get agreementRequired => 'Please accept the agreements first';
@override
String get codeSent => 'Code sent successfully';
@override
String get loginSuccess => 'Login success';
@override
String helloUser(String name) {
return 'Hi, $name';
}
@override
String get startJourney => 'Start Your Divination Journey';
@override
String get journeySubtitle => 'Explore possibilities with AI';
@override
String get startNow => 'Start Now';
@override
String get historyTitle => 'History';
@override
String get more => 'More';
@override
String get noRecords => 'No records yet';
@override
String get noRecordsSubtitle => 'You have not saved any records';
@override
String get homeTab => 'Home';
@override
String get profileTab => 'Me';
@override
String get notify => 'Notifications';
@override
String get featurePending => 'This feature is not connected yet';
@override
String get logout => 'Logout';
@override
String get defaultUserName => 'User';
@override
String get historyQuestion1 => 'Is this year a good time to change jobs?';
@override
String get historyQuestion2 => 'Can my relationship progress soon?';
@override
String get historyQuestion3 =>
'What pace should I keep for investments this quarter?';
@override
String get guaName1 => 'Wuwang';
@override
String get guaName2 => 'Ge';
@override
String get guaName3 => 'Guan';
@override
String get welcomeDialogTitle => 'Welcome to MeiYao Divination';
@override
String get welcomeParagraph1 =>
'Welcome to MeiYao Divination, an AI-assisted platform for interpreting traditional Six-Line divination and exploring Chinese classic wisdom.';
@override
String get welcomeParagraph2 =>
'Six-Line divination comes from the profound philosophy of the I Ching. It reflects how intention and timing are mapped into symbolic patterns.';
@override
String get welcomeParagraph3 =>
'MeiYao Divination helps you look beyond narrow thinking, see opportunities and risks from a broader trend perspective, and make clearer decisions.';
@override
String get warningTitle => 'Important Notice';
@override
String get warningBody =>
'All interpretations are AI-generated for entertainment only. Do not use them as professional advice for business, medical, or legal decisions.';
@override
String get scrollHint => 'Scroll down to read all';
@override
String get understood => 'I Understand';
@override
String get readAllFirst => 'Please read all first';
@override
String get categoryCareer => 'Career/Study';
@override
String get categoryLove => 'Love/Marriage';
@override
String get categoryMoney => 'Wealth/Investment';
@override
String get signBest => 'Excellent';
@override
String get signGood => 'Good';
@override
String get signNormal => 'Moderate';
@override
String get language => 'Language';
@override
String get english => 'English';
@override
String get chinese => 'Chinese';
@override
String get dialogConfirm => 'OK';
@override
String get agreementSeparator => ', ';
@override
String get agreementAnd => ' and ';
@override
String get privacyContent => 'Placeholder content for privacy policy.';
@override
String get termsContent => 'Placeholder content for terms of service.';
@override
String get disclaimerContent => 'Placeholder content for disclaimer.';
@override
String get toastLabelInfo => 'Info';
@override
String get toastLabelSuccess => 'Success';
@override
String get toastLabelWarning => 'Warning';
@override
String get toastLabelError => 'Error';
@override
String get errorTooManyRequests =>
'Too many requests, please try again later';
@override
String get errorInvalidVerificationCode => 'Invalid verification code';
@override
String get errorSessionExpired => 'Session expired, please login again';
@override
String get errorServiceUnavailable =>
'Service unavailable, please try again later';
@override
String get errorServerGeneric => 'Server error, please try again later';
@override
String get errorRequestGeneric => 'Request failed, please try again';
}
+243
View File
@@ -0,0 +1,243 @@
// ignore: unused_import
import 'package:intl/intl.dart' as intl;
import 'app_localizations.dart';
// ignore_for_file: type=lint
/// The translations for Chinese (`zh`).
class AppLocalizationsZh extends AppLocalizations {
AppLocalizationsZh([String locale = 'zh']) : super(locale);
@override
String get appTitle => '觅爻签问';
@override
String get welcomeLogin => '欢迎登录';
@override
String get loginSubtitle => '请使用邮箱登录';
@override
String get loginSubtitleEmail => '请使用邮箱登录';
@override
String get emailHint => '请输入邮箱地址';
@override
String get codeHint => '请输入验证码';
@override
String get sendCode => '获取验证码';
@override
String get sending => '发送中...';
@override
String retryAfter(int seconds) {
return '$seconds秒后重试';
}
@override
String get login => '登录';
@override
String get agreementPrefix => '我已阅读并同意';
@override
String get privacyPolicy => '隐私政策';
@override
String get termsOfService => '服务条款';
@override
String get disclaimer => '免责声明';
@override
String get icp => '粤ICP备2025428416号-1A';
@override
String get invalidPhone => '请输入正确的手机号码';
@override
String get invalidEmail => '请输入正确的邮箱地址';
@override
String get invalidCode => '请输入6位验证码';
@override
String get agreementRequired => '请先勾选协议';
@override
String get codeSent => '验证码已发送,请注意查收';
@override
String get loginSuccess => '登录成功';
@override
String helloUser(String name) {
return '您好,$name';
}
@override
String get startJourney => '开始您的卦象之旅';
@override
String get journeySubtitle => '借助AI智能,探索未来的可能';
@override
String get startNow => '立即起卦';
@override
String get historyTitle => '历史解卦';
@override
String get more => '更多';
@override
String get noRecords => '暂无记录';
@override
String get noRecordsSubtitle => '您并没有保存任何卦象';
@override
String get homeTab => '首页';
@override
String get profileTab => '我的';
@override
String get notify => '消息通知';
@override
String get featurePending => '该功能暂未接入数据';
@override
String get logout => '退出登录';
@override
String get defaultUserName => '用户';
@override
String get historyQuestion1 => '今年转岗是否合适?';
@override
String get historyQuestion2 => '最近感情是否能推进?';
@override
String get historyQuestion3 => '本季度投资节奏如何?';
@override
String get guaName1 => '天雷无妄';
@override
String get guaName2 => '泽火革';
@override
String get guaName3 => '风地观';
@override
String get welcomeDialogTitle => '欢迎使用觅爻签问';
@override
String get welcomeParagraph1 =>
'你好,欢迎来到觅爻签问,这是一个借助于AI解读传统六爻卦象的平台,为用户了解中国传统易学文化提供一个窗口。';
@override
String get welcomeParagraph2 =>
'六爻卦象源于《周易》深邃的哲学体系,是古人探索世界运行规律的一种独特方法。古人认为宇宙万物相互关联,在你起卦时,你的心念与时空信息会凝结成卦象的方式呈现出来。';
@override
String get welcomeParagraph3 =>
'觅爻签问基于这样的思路,帮助你跳出局限思维,从全局和演变趋势看清矛盾、机会与风险,为判断和行动提供参考。';
@override
String get warningTitle => '特别提醒';
@override
String get warningBody =>
'卦象解读结果均由AI生成,仅供娱乐参考,切不可作为商业、医疗等专业领域的决策依据。理性看待卦象,自由掌握人生。';
@override
String get scrollHint => '请向下滚动阅读全部内容';
@override
String get understood => '我已了解';
@override
String get readAllFirst => '请先阅读完整内容';
@override
String get categoryCareer => '事业学业';
@override
String get categoryLove => '情感婚姻';
@override
String get categoryMoney => '财富投资';
@override
String get signBest => '上上签';
@override
String get signGood => '中上签';
@override
String get signNormal => '中下签';
@override
String get language => '语言';
@override
String get english => '英文';
@override
String get chinese => '中文';
@override
String get dialogConfirm => '确定';
@override
String get agreementSeparator => '';
@override
String get agreementAnd => '';
@override
String get privacyContent => '隐私政策内容展示占位。';
@override
String get termsContent => '服务条款内容展示占位。';
@override
String get disclaimerContent => '免责声明内容展示占位。';
@override
String get toastLabelInfo => '提示';
@override
String get toastLabelSuccess => '成功';
@override
String get toastLabelWarning => '警告';
@override
String get toastLabelError => '错误';
@override
String get errorTooManyRequests => '请求过于频繁,请稍后重试';
@override
String get errorInvalidVerificationCode => '验证码错误';
@override
String get errorSessionExpired => '登录已过期,请重新登录';
@override
String get errorServiceUnavailable => '服务暂时不可用,请稍后重试';
@override
String get errorServerGeneric => '服务异常,请稍后重试';
@override
String get errorRequestGeneric => '请求失败,请稍后重试';
}
+27 -4
View File
@@ -2,8 +2,9 @@
"@@locale": "zh",
"appTitle": "觅爻签问",
"welcomeLogin": "欢迎登录",
"loginSubtitle": "请使用手机号登录",
"phoneHint": "请输入手机号码",
"loginSubtitle": "请使用邮箱登录",
"loginSubtitleEmail": "请使用邮箱登录",
"emailHint": "请输入邮箱地址",
"codeHint": "请输入验证码",
"sendCode": "获取验证码",
"sending": "发送中...",
@@ -22,10 +23,11 @@
"disclaimer": "免责声明",
"icp": "粤ICP备2025428416号-1A",
"invalidPhone": "请输入正确的手机号码",
"invalidEmail": "请输入正确的邮箱地址",
"invalidCode": "请输入6位验证码",
"agreementRequired": "请先勾选协议",
"codeSent": "验证码已发送,请注意查收",
"mockLoginSuccess": "模拟登录成功",
"loginSuccess": "登录成功",
"helloUser": "您好,{name}",
"@helloUser": {
"placeholders": {
@@ -45,6 +47,14 @@
"profileTab": "我的",
"notify": "消息通知",
"featurePending": "该功能暂未接入数据",
"logout": "退出登录",
"defaultUserName": "用户",
"historyQuestion1": "今年转岗是否合适?",
"historyQuestion2": "最近感情是否能推进?",
"historyQuestion3": "本季度投资节奏如何?",
"guaName1": "天雷无妄",
"guaName2": "泽火革",
"guaName3": "风地观",
"welcomeDialogTitle": "欢迎使用觅爻签问",
"welcomeParagraph1": "你好,欢迎来到觅爻签问,这是一个借助于AI解读传统六爻卦象的平台,为用户了解中国传统易学文化提供一个窗口。",
"welcomeParagraph2": "六爻卦象源于《周易》深邃的哲学体系,是古人探索世界运行规律的一种独特方法。古人认为宇宙万物相互关联,在你起卦时,你的心念与时空信息会凝结成卦象的方式呈现出来。",
@@ -63,7 +73,20 @@
"language": "语言",
"english": "英文",
"chinese": "中文",
"dialogConfirm": "确定",
"agreementSeparator": "、",
"agreementAnd": "和",
"privacyContent": "隐私政策内容展示占位。",
"termsContent": "服务条款内容展示占位。",
"disclaimerContent": "免责声明内容展示占位。"
"disclaimerContent": "免责声明内容展示占位。",
"toastLabelInfo": "提示",
"toastLabelSuccess": "成功",
"toastLabelWarning": "警告",
"toastLabelError": "错误",
"errorTooManyRequests": "请求过于频繁,请稍后重试",
"errorInvalidVerificationCode": "验证码错误",
"errorSessionExpired": "登录已过期,请重新登录",
"errorServiceUnavailable": "服务暂时不可用,请稍后重试",
"errorServerGeneric": "服务异常,请稍后重试",
"errorRequestGeneric": "请求失败,请稍后重试"
}
+18 -1
View File
@@ -1,8 +1,25 @@
import 'package:flutter/widgets.dart';
import 'app/app.dart';
import 'app/di/injection.dart';
import 'core/config/env.dart';
import 'core/logging/error_handler.dart';
import 'core/logging/log_service.dart';
import 'core/logging/logger.dart';
void main() {
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
final logService = await LogService.create();
Logger.setLogService(logService);
AppErrorHandler().register();
await Env.init();
await configureDependencies();
getLogger('app').info(
message: 'App starting',
extra: {'backend_url': appDependencies.backendUrl},
);
runApp(const EryaoApp());
}
@@ -0,0 +1,136 @@
import 'package:flutter/material.dart';
@immutable
class AppColorPalette extends ThemeExtension<AppColorPalette> {
const AppColorPalette({
required this.accentPurple,
required this.historyGoldBg,
required this.historyGoldText,
required this.historyBlueBg,
required this.historyBlueText,
required this.historyGrayBg,
required this.historyGrayText,
required this.categoryCareerBg,
required this.categoryCareerText,
required this.categoryLoveBg,
required this.categoryLoveText,
required this.categoryMoneyBg,
required this.categoryMoneyText,
required this.notificationDot,
required this.warning,
required this.warningContainer,
required this.onWarningContainer,
});
final Color accentPurple;
final Color historyGoldBg;
final Color historyGoldText;
final Color historyBlueBg;
final Color historyBlueText;
final Color historyGrayBg;
final Color historyGrayText;
final Color categoryCareerBg;
final Color categoryCareerText;
final Color categoryLoveBg;
final Color categoryLoveText;
final Color categoryMoneyBg;
final Color categoryMoneyText;
final Color notificationDot;
final Color warning;
final Color warningContainer;
final Color onWarningContainer;
@override
ThemeExtension<AppColorPalette> copyWith({
Color? accentPurple,
Color? historyGoldBg,
Color? historyGoldText,
Color? historyBlueBg,
Color? historyBlueText,
Color? historyGrayBg,
Color? historyGrayText,
Color? categoryCareerBg,
Color? categoryCareerText,
Color? categoryLoveBg,
Color? categoryLoveText,
Color? categoryMoneyBg,
Color? categoryMoneyText,
Color? notificationDot,
Color? warning,
Color? warningContainer,
Color? onWarningContainer,
}) {
return AppColorPalette(
accentPurple: accentPurple ?? this.accentPurple,
historyGoldBg: historyGoldBg ?? this.historyGoldBg,
historyGoldText: historyGoldText ?? this.historyGoldText,
historyBlueBg: historyBlueBg ?? this.historyBlueBg,
historyBlueText: historyBlueText ?? this.historyBlueText,
historyGrayBg: historyGrayBg ?? this.historyGrayBg,
historyGrayText: historyGrayText ?? this.historyGrayText,
categoryCareerBg: categoryCareerBg ?? this.categoryCareerBg,
categoryCareerText: categoryCareerText ?? this.categoryCareerText,
categoryLoveBg: categoryLoveBg ?? this.categoryLoveBg,
categoryLoveText: categoryLoveText ?? this.categoryLoveText,
categoryMoneyBg: categoryMoneyBg ?? this.categoryMoneyBg,
categoryMoneyText: categoryMoneyText ?? this.categoryMoneyText,
notificationDot: notificationDot ?? this.notificationDot,
warning: warning ?? this.warning,
warningContainer: warningContainer ?? this.warningContainer,
onWarningContainer: onWarningContainer ?? this.onWarningContainer,
);
}
@override
ThemeExtension<AppColorPalette> lerp(
covariant ThemeExtension<AppColorPalette>? other,
double t,
) {
if (other is! AppColorPalette) {
return this;
}
return AppColorPalette(
accentPurple: Color.lerp(accentPurple, other.accentPurple, t)!,
historyGoldBg: Color.lerp(historyGoldBg, other.historyGoldBg, t)!,
historyGoldText: Color.lerp(historyGoldText, other.historyGoldText, t)!,
historyBlueBg: Color.lerp(historyBlueBg, other.historyBlueBg, t)!,
historyBlueText: Color.lerp(historyBlueText, other.historyBlueText, t)!,
historyGrayBg: Color.lerp(historyGrayBg, other.historyGrayBg, t)!,
historyGrayText: Color.lerp(historyGrayText, other.historyGrayText, t)!,
categoryCareerBg: Color.lerp(
categoryCareerBg,
other.categoryCareerBg,
t,
)!,
categoryCareerText: Color.lerp(
categoryCareerText,
other.categoryCareerText,
t,
)!,
categoryLoveBg: Color.lerp(categoryLoveBg, other.categoryLoveBg, t)!,
categoryLoveText: Color.lerp(
categoryLoveText,
other.categoryLoveText,
t,
)!,
categoryMoneyBg: Color.lerp(categoryMoneyBg, other.categoryMoneyBg, t)!,
categoryMoneyText: Color.lerp(
categoryMoneyText,
other.categoryMoneyText,
t,
)!,
notificationDot: Color.lerp(notificationDot, other.notificationDot, t)!,
warning: Color.lerp(warning, other.warning, t)!,
warningContainer: Color.lerp(
warningContainer,
other.warningContainer,
t,
)!,
onWarningContainer: Color.lerp(
onWarningContainer,
other.onWarningContainer,
t,
)!,
);
}
}
+17
View File
@@ -0,0 +1,17 @@
class AppSpacing {
static const double xs = 4;
static const double sm = 8;
static const double md = 12;
static const double lg = 16;
static const double xl = 24;
static const double xxl = 32;
static const double xxxl = 60;
}
class AppRadius {
static const double sm = 8;
static const double md = 12;
static const double lg = 16;
static const double xl = 20;
static const double full = 999;
}
+76
View File
@@ -0,0 +1,76 @@
import 'package:flutter/material.dart';
import '../theme/design_tokens.dart';
import 'toast/toast_type.dart';
import 'toast/toast_type_config.dart' show ToastTypeConfig;
class AppBanner extends StatelessWidget {
final String message;
final ToastType type;
final bool visible;
final String? title;
const AppBanner({
super.key,
required this.message,
this.type = ToastType.warning,
this.visible = true,
this.title,
});
@override
Widget build(BuildContext context) {
if (!visible) return const SizedBox.shrink();
final config = ToastTypeConfig.fromType(context, type);
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
decoration: BoxDecoration(
color: config.surfaceColor,
borderRadius: BorderRadius.circular(AppRadius.md),
border: Border.all(color: config.borderColor),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 28,
height: 28,
decoration: BoxDecoration(
color: config.iconColor.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(AppRadius.full),
),
child: Icon(config.icon, size: 16, color: config.iconColor),
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title ?? config.label,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: config.textColor,
),
),
const SizedBox(height: 2),
Text(
message,
style: TextStyle(
fontSize: 13,
height: 1.35,
color: config.textColor,
),
),
],
),
),
],
),
);
}
}
@@ -0,0 +1,104 @@
import 'package:flutter/material.dart';
import '../theme/design_tokens.dart';
enum AppLoadingVariant { surface, inline, button }
class AppLoadingIndicator extends StatelessWidget {
const AppLoadingIndicator({
super.key,
this.variant = AppLoadingVariant.surface,
this.size,
this.strokeWidth,
this.color,
this.trackColor,
this.withContainer,
});
final AppLoadingVariant variant;
final double? size;
final double? strokeWidth;
final Color? color;
final Color? trackColor;
final bool? withContainer;
double get _resolvedSize {
return size ??
switch (variant) {
AppLoadingVariant.surface => 22,
AppLoadingVariant.inline => 16,
AppLoadingVariant.button => 18,
};
}
double get _resolvedStrokeWidth {
return strokeWidth ??
switch (variant) {
AppLoadingVariant.surface => 2.2,
AppLoadingVariant.inline => 2,
AppLoadingVariant.button => 2.2,
};
}
Widget _buildSpinner(Color color, Color trackColor) {
return SizedBox(
width: _resolvedSize,
height: _resolvedSize,
child: CircularProgressIndicator(
strokeWidth: _resolvedStrokeWidth,
color: color,
backgroundColor: trackColor,
),
);
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final resolvedColor =
color ??
switch (variant) {
AppLoadingVariant.surface => colorScheme.primary,
AppLoadingVariant.inline => colorScheme.onSurfaceVariant,
AppLoadingVariant.button => colorScheme.onPrimary,
};
final resolvedTrackColor =
trackColor ??
switch (variant) {
AppLoadingVariant.surface => colorScheme.primaryContainer,
AppLoadingVariant.inline => colorScheme.outlineVariant,
AppLoadingVariant.button => colorScheme.secondary,
};
if (withContainer == false ||
(withContainer == null &&
switch (variant) {
AppLoadingVariant.surface => true,
AppLoadingVariant.inline => false,
AppLoadingVariant.button => false,
})) {
return _buildSpinner(resolvedColor, resolvedTrackColor);
}
return Container(
width: _resolvedSize + AppSpacing.md,
height: _resolvedSize + AppSpacing.md,
padding: const EdgeInsets.all(AppSpacing.xs),
decoration: BoxDecoration(
color: colorScheme.surface,
borderRadius: BorderRadius.circular(AppRadius.full),
border: Border.all(color: colorScheme.outlineVariant),
boxShadow: [
BoxShadow(
color: colorScheme.outlineVariant.withValues(alpha: 0.55),
blurRadius: AppRadius.md,
offset: const Offset(0, AppSpacing.xs),
),
],
),
child: _buildSpinner(resolvedColor, resolvedTrackColor),
);
}
}
+103
View File
@@ -0,0 +1,103 @@
import 'package:flutter/material.dart';
import '../../l10n/app_localizations.dart';
import '../theme/design_tokens.dart';
enum MainTab { home, profile }
class BottomNavBar extends StatelessWidget {
const BottomNavBar({
super.key,
required this.currentTab,
required this.onTabChange,
required this.onLogoTap,
});
final MainTab currentTab;
final ValueChanged<MainTab> onTabChange;
final VoidCallback onLogoTap;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final colors = Theme.of(context).colorScheme;
return Container(
padding: const EdgeInsets.symmetric(vertical: AppSpacing.sm),
color: colors.surface,
child: SafeArea(
top: false,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_NavItem(
icon: Icons.home,
label: l10n.homeTab,
selected: currentTab == MainTab.home,
onTap: () => onTabChange(MainTab.home),
),
GestureDetector(
onTap: onLogoTap,
child: ClipRRect(
borderRadius: BorderRadius.circular(AppRadius.lg),
child: Image.asset(
'assets/images/logo.png',
width: 56,
height: 56,
fit: BoxFit.cover,
),
),
),
_NavItem(
icon: Icons.person,
label: l10n.profileTab,
selected: currentTab == MainTab.profile,
onTap: () => onTabChange(MainTab.profile),
),
],
),
),
);
}
}
class _NavItem extends StatelessWidget {
const _NavItem({
required this.icon,
required this.label,
required this.selected,
required this.onTap,
});
final IconData icon;
final String label;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final iconColor = selected ? colors.primary : colors.outline;
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(AppRadius.md),
child: Padding(
padding: const EdgeInsets.all(AppSpacing.sm),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, color: iconColor),
const SizedBox(height: AppSpacing.xs),
Text(
label,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: iconColor),
),
],
),
),
);
}
}
+183
View File
@@ -0,0 +1,183 @@
import 'package:flutter/material.dart';
import '../../theme/design_tokens.dart';
import 'toast_type.dart';
import 'toast_type_config.dart';
class Toast {
static void show(
BuildContext context,
String message, {
ToastType type = ToastType.info,
Duration duration = const Duration(seconds: 2),
}) {
final overlay = Overlay.of(context);
late OverlayEntry entry;
entry = OverlayEntry(
builder: (context) => _ToastWidget(
message: message,
type: type,
duration: duration,
onDismiss: () => entry.remove(),
),
);
overlay.insert(entry);
}
}
class _ToastWidget extends StatefulWidget {
final String message;
final ToastType type;
final Duration duration;
final VoidCallback onDismiss;
const _ToastWidget({
required this.message,
required this.type,
required this.duration,
required this.onDismiss,
});
@override
State<_ToastWidget> createState() => _ToastWidgetState();
}
class _ToastWidgetState extends State<_ToastWidget>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<Offset> _slideAnimation;
late Animation<double> _fadeAnimation;
bool _dismissed = false;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 280),
vsync: this,
);
_slideAnimation = Tween<Offset>(
begin: const Offset(0, -0.18),
end: Offset.zero,
).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic));
_fadeAnimation = Tween<double>(
begin: 0,
end: 1,
).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOut));
_controller.forward();
Future.delayed(widget.duration, _dismiss);
}
void _dismiss() {
if (!mounted || _dismissed) return;
_dismissed = true;
_controller.reverse().then((_) {
if (mounted) {
widget.onDismiss();
}
});
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final config = ToastTypeConfig.fromType(context, widget.type);
final colorScheme = Theme.of(context).colorScheme;
return Positioned(
top: MediaQuery.of(context).padding.top + 12,
left: 16,
right: 16,
child: SlideTransition(
position: _slideAnimation,
child: FadeTransition(
opacity: _fadeAnimation,
child: Material(
color: Colors.transparent,
child: SafeArea(
bottom: false,
child: GestureDetector(
onTap: _dismiss,
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: config.surfaceColor,
borderRadius: BorderRadius.circular(AppRadius.lg),
border: Border.all(color: config.borderColor),
boxShadow: [
BoxShadow(
color: colorScheme.shadow.withValues(alpha: 0.08),
blurRadius: 24,
offset: const Offset(0, 10),
),
],
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 34,
height: 34,
decoration: BoxDecoration(
color: config.iconColor.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(AppRadius.full),
),
child: Icon(
config.icon,
size: 18,
color: config.iconColor,
),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
config.label,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: config.textColor,
),
),
const SizedBox(height: 2),
Text(
widget.message,
style: TextStyle(
fontSize: 14,
height: 1.35,
color: config.textColor,
),
),
],
),
),
const SizedBox(width: 8),
Icon(
Icons.close_rounded,
size: 18,
color: config.textColor.withValues(alpha: 0.72),
),
],
),
),
),
),
),
),
),
);
}
}
@@ -0,0 +1 @@
enum ToastType { info, success, warning, error }
@@ -0,0 +1,64 @@
import 'package:flutter/material.dart';
import '../../../l10n/app_localizations.dart';
import '../../theme/app_color_palette.dart';
import 'toast_type.dart';
class ToastTypeConfig {
final Color surfaceColor;
final Color borderColor;
final Color iconColor;
final Color textColor;
final String label;
final IconData icon;
const ToastTypeConfig({
required this.surfaceColor,
required this.borderColor,
required this.iconColor,
required this.textColor,
required this.label,
required this.icon,
});
static ToastTypeConfig fromType(BuildContext context, ToastType type) {
final l10n = AppLocalizations.of(context)!;
final colorScheme = Theme.of(context).colorScheme;
final palette = Theme.of(context).extension<AppColorPalette>()!;
return switch (type) {
ToastType.success => ToastTypeConfig(
surfaceColor: colorScheme.primaryContainer,
borderColor: colorScheme.primary,
iconColor: colorScheme.primary,
textColor: colorScheme.onPrimaryContainer,
label: l10n.toastLabelSuccess,
icon: Icons.check_circle_outline,
),
ToastType.warning => ToastTypeConfig(
surfaceColor: palette.warningContainer,
borderColor: palette.warning,
iconColor: palette.warning,
textColor: palette.onWarningContainer,
label: l10n.toastLabelWarning,
icon: Icons.warning_amber_rounded,
),
ToastType.error => ToastTypeConfig(
surfaceColor: colorScheme.errorContainer,
borderColor: colorScheme.error,
iconColor: colorScheme.error,
textColor: colorScheme.onErrorContainer,
label: l10n.toastLabelError,
icon: Icons.error_outline,
),
ToastType.info => ToastTypeConfig(
surfaceColor: colorScheme.primaryContainer,
borderColor: colorScheme.primary,
iconColor: colorScheme.primary,
textColor: colorScheme.onPrimaryContainer,
label: l10n.toastLabelInfo,
icon: Icons.info_outline,
),
};
}
}
+11
View File
@@ -34,6 +34,9 @@ dependencies:
sdk: flutter
intl: ^0.20.2
shared_preferences: ^2.5.3
flutter_secure_storage: ^9.2.4
dio: ^5.9.0
path_provider: ^2.1.5
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
@@ -49,6 +52,14 @@ dev_dependencies:
# package. See that file for information about deactivating specific lint
# rules and activating additional ones.
flutter_lints: ^6.0.0
flutter_launcher_icons: ^0.14.4
flutter_launcher_icons:
android: true
ios: true
image_path: assets/images/logo.png
adaptive_icon_background: "#FFFFFF"
adaptive_icon_foreground: assets/images/logo.png
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec
+167
View File
@@ -0,0 +1,167 @@
# Visual Design Language for Eryao Flutter App
This document defines the **visual design language** for `觅爻签问` under `apps/**`.
It is derived from:
- the existing product visuals in `old/app/**`
- the structure and quality bar of `social-app/apps/rules/visual_design_language.md`
It is the source of truth for visual consistency, surface hierarchy, and interaction tone.
---
## 0) Scope and Role (MUST)
- Applies to all Flutter UI work in `apps/**`.
- Defines visual intent, not business logic.
- If implementation constraints conflict with visual intent, keep the stricter implementation rule and preserve visual intent as much as possible.
---
## 1) Product Design Goal (MUST)
`觅爻签问` is a divination assistant app with Chinese cultural context.
The UI must feel:
- calm
- trustworthy
- warm-tech
- clear
- ceremonial but not mystical noise
- modern mobile-native
Avoid:
- enterprise admin panel aesthetics
- hyper-playful toy style
- over-dark cyber style
- ornamental visual clutter
---
## 2) Core Style Direction (MUST)
Primary style blend:
- **purple-centered brand palette** from old app
- **soft gray background field**
- **white card surfaces with rounded corners**
- **gradient hero card for primary action**
- **compact tag system for category/sign states**
Visual tone:
- soft, layered, readable
- lightweight but premium
- expressive in key actions, restrained elsewhere
---
## 3) Brand Palette (MUST)
Canonical colors extracted from old app:
- Primary Purple: `#673AB7`
- Accent Purple: `#9C27B0`
- Light Purple Surface: `#F0E6FF`
- Background Gray: `#F8F8F8`
- Text Dark: `#333333`
- Text Medium: `#666666`
- Text Light: `#999999`
Tag/status support colors:
- Gold result: bg `#FFF8E1`, text `#FFB300`
- Blue tag: bg `#E6F7FF`, text `#1890FF`
- Gray result: bg `#F5F5F5`, text `#9E9E9E`
- Warning: `#F57C00`
All UI usage should go through `ColorScheme` and `AppColorPalette` extension.
---
## 4) Surface Hierarchy (MUST)
Every screen should read in this order:
1. soft background field (`BackgroundGray` semantic slot)
2. core card modules (white rounded surfaces)
3. prominent primary-action hero card (purple gradient)
4. compact metadata chips (category/gua/sign)
5. subtle transient feedback (toast/snackbar)
Do not rely on color alone; use spacing, corner radius, and elevation to express hierarchy.
---
## 5) Shape and Spacing Language (MUST)
- Rounded geometry is default.
- Primary card radius: `16dp`.
- Secondary card radius: `12dp`.
- Input/button radius: `8dp`.
- Spacing rhythm follows `4/8/12/16/24/32`.
- Preserve generous vertical breathing room on login and home.
---
## 6) Typography and Tone (MUST)
- Headline: strong, concise.
- Body: readable and neutral.
- Caption/meta: low emphasis but still legible.
- Chinese copy should remain culturally natural and non-gimmicky.
- English copy should be concise, product-appropriate, and semantically aligned.
---
## 7) Page-Level Guidance (MUST)
### Login
- Top-left welcome title + subtitle.
- Phone input and code input row with fixed-width code button.
- Primary full-width login button.
- Agreement checkbox with inline clickable legal links.
- Bottom filing/registration text centered.
### Home
- Top greeting on the left, notification icon on the right with red-dot state.
- Gradient hero card with icon, title, subtitle, and main CTA.
- History section title + “more” entry.
- Vertical list of rounded history cards with compact status tags.
- Bottom nav: Home, center logo, Profile.
- First-entry modal: long welcome text requiring scroll-to-bottom confirmation.
---
## 8) Interaction and Motion (SHOULD)
- Keep transitions subtle and quick.
- Avoid heavy animation chains.
- Primary actions provide immediate feedback.
- Disabled states must be visually clear.
---
## 9) Accessibility and Internationalization (MUST)
- Support at least `zh` and `en`.
- Keep text scalable without overlap in critical controls.
- Ensure interactive targets are easy to tap.
- Contrast should remain readable on all key text/surface combinations.
---
## 10) Android Continuity Requirements (MUST)
To avoid losing local tokens/settings during upgrades:
- Keep stable `applicationId` across releases.
- Keep release signing keystore stable per channel.
- Persist token/session in local key-value storage.
- Keep backup/data-extraction rules explicit for shared preferences.
These are release continuity requirements and must be checked before each Android release.
@@ -0,0 +1,36 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:meeyao_qianwen/core/network/api_problem.dart';
import 'package:meeyao_qianwen/core/network/api_problem_mapper.dart';
import 'package:meeyao_qianwen/l10n/app_localizations.dart';
void main() {
testWidgets('map by code uses localized message', (tester) async {
late AppLocalizations l10n;
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Builder(
builder: (context) {
l10n = AppLocalizations.of(context)!;
return const SizedBox.shrink();
},
),
),
);
final message = mapApiProblemToMessage(
ApiProblem(
status: 401,
title: 'Unauthorized',
detail: 'Invalid verification code',
code: 'AUTH_VERIFICATION_CODE_INVALID',
),
l10n,
);
expect(message, l10n.errorInvalidVerificationCode);
});
}
@@ -0,0 +1,87 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:meeyao_qianwen/features/auth/data/models/auth_user.dart';
import 'package:meeyao_qianwen/features/auth/data/repositories/auth_repository.dart';
import 'package:meeyao_qianwen/features/auth/presentation/bloc/auth_bloc.dart';
import 'package:meeyao_qianwen/features/auth/presentation/bloc/auth_state.dart';
class _FakeAuthRepository implements AuthRepository {
_FakeAuthRepository({this.recoveredUser, this.throwOnLogout = false});
AuthUser? recoveredUser;
bool clearCalled = false;
bool logoutCalled = false;
bool throwOnLogout;
@override
Future<void> clearLocalSession() async {
clearCalled = true;
}
@override
Future<AuthUser> loginWithEmailOtp({
required String email,
required String otp,
}) async {
return AuthUser(id: 'u1', email: email);
}
@override
Future<void> logout() async {
logoutCalled = true;
if (throwOnLogout) {
throw Exception('logout failed');
}
}
@override
Future<AuthUser?> recoverSession() async {
return recoveredUser;
}
@override
Future<void> sendOtp(String email) async {}
}
void main() {
test('start should become authenticated when recover success', () async {
final repo = _FakeAuthRepository(
recoveredUser: const AuthUser(id: 'u1', email: 'a@b.com'),
);
final bloc = AuthBloc(repository: repo);
await bloc.start();
expect(bloc.state.status, AuthStatus.authenticated);
expect(bloc.state.user?.email, 'a@b.com');
});
test('handleUnauthorized401 should clear local session and unauth', () async {
final repo = _FakeAuthRepository(
recoveredUser: const AuthUser(id: 'u1', email: 'a@b.com'),
);
final bloc = AuthBloc(repository: repo);
await bloc.start();
await bloc.handleUnauthorized401();
expect(repo.clearCalled, isTrue);
expect(bloc.state.status, AuthStatus.unauthenticated);
});
test(
'logout should set unauthenticated even when repository throws',
() async {
final repo = _FakeAuthRepository(
recoveredUser: const AuthUser(id: 'u1', email: 'a@b.com'),
throwOnLogout: true,
);
final bloc = AuthBloc(repository: repo);
await bloc.start();
await expectLater(bloc.logout(), throwsA(isA<Exception>()));
expect(repo.logoutCalled, isTrue);
expect(bloc.state.status, AuthStatus.unauthenticated);
},
);
}
@@ -0,0 +1,59 @@
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:meeyao_qianwen/core/auth/session_store.dart';
import 'package:meeyao_qianwen/app/app_theme.dart';
import 'package:meeyao_qianwen/data/storage/local_kv_store.dart';
import 'package:meeyao_qianwen/features/home/presentation/screens/home_screen.dart';
import 'package:meeyao_qianwen/l10n/app_localizations.dart';
class _FakeSessionStore extends SessionStore {
_FakeSessionStore({required this.hasReadWelcomeValue})
: super(LocalKvStore());
bool hasReadWelcomeValue;
bool setWelcomeReadCalled = false;
@override
Future<bool> hasReadWelcome() async {
return hasReadWelcomeValue;
}
@override
Future<void> setWelcomeRead(bool value) async {
setWelcomeReadCalled = value;
}
}
void main() {
testWidgets('history cards should use full available width', (tester) async {
final sessionStore = _FakeSessionStore(hasReadWelcomeValue: true);
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: AppLocalizations.supportedLocales,
home: HomeScreen(
account: 'user@example.com',
sessionStore: sessionStore,
onLogout: () async {},
),
),
);
await tester.pumpAndSettle();
final historyCard = find.byType(Card).first;
final cardWidth = tester.getSize(historyCard).width;
final viewportWidth =
tester.view.physicalSize.width / tester.view.devicePixelRatio;
expect(cardWidth, viewportWidth);
});
}
+4 -24
View File
@@ -1,30 +1,10 @@
// This is a basic Flutter widget test.
//
// To perform an interaction with a widget in your test, use the WidgetTester
// utility in the flutter_test package. For example, you can send tap and scroll
// gestures. You can also use WidgetTester to find child widgets in the widget
// tree, read text, and verify that the values of widget properties are correct.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:meeyao_qianwen/main.dart';
import 'package:meeyao_qianwen/app/app.dart';
void main() {
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
// Build our app and trigger a frame.
await tester.pumpWidget(const MyApp());
// Verify that our counter starts at 0.
expect(find.text('0'), findsOneWidget);
expect(find.text('1'), findsNothing);
// Tap the '+' icon and trigger a frame.
await tester.tap(find.byIcon(Icons.add));
await tester.pump();
// Verify that our counter has incremented.
expect(find.text('0'), findsNothing);
expect(find.text('1'), findsOneWidget);
testWidgets('app bootstraps', (WidgetTester tester) async {
await tester.pumpWidget(const EryaoApp());
expect(find.byType(EryaoApp), findsOneWidget);
});
}
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
BACKEND_URL=""
DEVICE_ARGS=()
usage() {
cat <<EOF
Usage:
$0 [--backend-url http://host:port] [flutter run args...]
Examples:
$0
$0 --backend-url http://192.168.1.100:5775
$0 --backend-url http://10.0.2.2:5775 -d emulator-5554
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--backend-url)
BACKEND_URL="$2"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
DEVICE_ARGS+=("$1")
shift
;;
esac
done
cd "$ROOT_DIR"
if [[ -n "$BACKEND_URL" ]]; then
flutter run --dart-define="BACKEND_URL=$BACKEND_URL" "${DEVICE_ARGS[@]}"
else
flutter run "${DEVICE_ARGS[@]}"
fi