feat: 切换邮箱认证并重构前后端启动与门禁
@@ -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)
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
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>
|
||||
|
Before Width: | Height: | Size: 544 B After Width: | Height: | Size: 7.4 KiB |
|
Before Width: | Height: | Size: 442 B After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 721 B After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 24 KiB |
|
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>
|
||||
@@ -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"}}
|
||||
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 462 KiB |
|
Before Width: | Height: | Size: 295 B After Width: | Height: | Size: 962 B |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 450 B After Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 282 B After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 462 B After Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 704 B After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 586 B After Width: | Height: | Size: 8.6 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 762 B After Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 30 KiB |
@@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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() ?? [];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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, you’ll need to edit this
|
||||
/// file.
|
||||
///
|
||||
/// First, open your project’s ios/Runner.xcworkspace Xcode workspace file.
|
||||
/// Then, in the Project Navigator, open the Info.plist file under the Runner
|
||||
/// project’s 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.',
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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 => '请求失败,请稍后重试';
|
||||
}
|
||||
@@ -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": "请求失败,请稍后重试"
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)!,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||