965 lines
22 KiB
Markdown
965 lines
22 KiB
Markdown
# LinguaForge MVP 技術規格文件
|
||
|
||
## 1. 技術架構總覽
|
||
|
||
```
|
||
┌─────────────────────────────────────────┐
|
||
│ Flutter App │
|
||
│ ┌─────────────────────────────────┐ │
|
||
│ │ Presentation Layer │ │
|
||
│ │ (Screens & Widgets) │ │
|
||
│ └─────────────────────────────────┘ │
|
||
│ ┌─────────────────────────────────┐ │
|
||
│ │ Business Logic │ │
|
||
│ │ (Provider State Management) │ │
|
||
│ └─────────────────────────────────┘ │
|
||
│ ┌─────────────────────────────────┐ │
|
||
│ │ Data Layer │ │
|
||
│ │ (Repositories & Services) │ │
|
||
│ └─────────────────────────────────┘ │
|
||
└─────────────────────────────────────────┘
|
||
↓
|
||
┌───────────────────────┐
|
||
│ External Services │
|
||
├───────────────────────┤
|
||
│ • Firebase Auth │
|
||
│ • Supabase Database │
|
||
│ • Gemini API │
|
||
│ • Local Storage │
|
||
└───────────────────────┘
|
||
```
|
||
|
||
## 2. 技術棧詳細規格
|
||
|
||
### 2.1 前端技術棧
|
||
|
||
```yaml
|
||
Flutter:
|
||
版本: 3.16.0+
|
||
Dart: 3.2.0+
|
||
|
||
核心套件:
|
||
狀態管理:
|
||
provider: ^6.1.0
|
||
|
||
網路請求:
|
||
dio: ^5.4.0
|
||
dio_retry: ^4.1.0
|
||
|
||
本地存儲:
|
||
hive: ^2.2.3
|
||
hive_flutter: ^1.1.0
|
||
|
||
認證:
|
||
firebase_auth: ^4.15.0
|
||
google_sign_in: ^6.1.0
|
||
|
||
資料庫:
|
||
supabase_flutter: ^2.0.0
|
||
|
||
UI 組件:
|
||
flutter_screenutil: ^5.9.0
|
||
shimmer: ^3.0.0
|
||
lottie: ^2.7.0
|
||
|
||
工具類:
|
||
intl: ^0.18.0
|
||
uuid: ^4.2.0
|
||
connectivity_plus: ^5.0.0
|
||
```
|
||
|
||
### 2.2 後端服務
|
||
|
||
```yaml
|
||
Supabase:
|
||
資料庫: PostgreSQL 15
|
||
即時訂閱: Realtime
|
||
檔案存儲: Storage
|
||
Edge Functions: Deno
|
||
|
||
Firebase:
|
||
認證: Firebase Auth
|
||
崩潰報告: Crashlytics
|
||
效能監控: Performance
|
||
分析: Analytics
|
||
```
|
||
|
||
## 3. 專案結構
|
||
|
||
```
|
||
lib/
|
||
├── main.dart
|
||
├── app.dart
|
||
├── config/
|
||
│ ├── constants.dart
|
||
│ ├── theme.dart
|
||
│ └── routes.dart
|
||
├── core/
|
||
│ ├── errors/
|
||
│ │ ├── exceptions.dart
|
||
│ │ └── failures.dart
|
||
│ ├── utils/
|
||
│ │ ├── validators.dart
|
||
│ │ └── formatters.dart
|
||
│ └── extensions/
|
||
│ └── string_extensions.dart
|
||
├── data/
|
||
│ ├── models/
|
||
│ │ ├── user_model.dart
|
||
│ │ ├── card_model.dart
|
||
│ │ └── review_model.dart
|
||
│ ├── repositories/
|
||
│ │ ├── auth_repository.dart
|
||
│ │ ├── card_repository.dart
|
||
│ │ └── review_repository.dart
|
||
│ └── services/
|
||
│ ├── api_service.dart
|
||
│ ├── gemini_service.dart
|
||
│ └── storage_service.dart
|
||
├── domain/
|
||
│ ├── entities/
|
||
│ │ ├── user.dart
|
||
│ │ ├── card.dart
|
||
│ │ └── review.dart
|
||
│ └── usecases/
|
||
│ ├── auth/
|
||
│ ├── cards/
|
||
│ └── review/
|
||
├── presentation/
|
||
│ ├── providers/
|
||
│ │ ├── auth_provider.dart
|
||
│ │ ├── card_provider.dart
|
||
│ │ └── review_provider.dart
|
||
│ ├── screens/
|
||
│ │ ├── auth/
|
||
│ │ ├── home/
|
||
│ │ ├── cards/
|
||
│ │ └── review/
|
||
│ └── widgets/
|
||
│ ├── common/
|
||
│ └── cards/
|
||
└── l10n/
|
||
└── app_zh.arb
|
||
```
|
||
|
||
## 4. 核心模組實作
|
||
|
||
### 4.1 認證模組
|
||
|
||
```dart
|
||
// lib/data/repositories/auth_repository.dart
|
||
class AuthRepository {
|
||
final FirebaseAuth _firebaseAuth;
|
||
final SupabaseClient _supabase;
|
||
|
||
AuthRepository({
|
||
required FirebaseAuth firebaseAuth,
|
||
required SupabaseClient supabase,
|
||
}) : _firebaseAuth = firebaseAuth,
|
||
_supabase = supabase;
|
||
|
||
// 註冊
|
||
Future<User> signUp({
|
||
required String email,
|
||
required String password,
|
||
String? nickname,
|
||
}) async {
|
||
try {
|
||
// 1. Firebase 註冊
|
||
final credential = await _firebaseAuth.createUserWithEmailAndPassword(
|
||
email: email,
|
||
password: password,
|
||
);
|
||
|
||
// 2. 取得 ID Token
|
||
final idToken = await credential.user?.getIdToken();
|
||
|
||
// 3. Supabase 創建用戶資料
|
||
await _supabase.from('users').insert({
|
||
'id': credential.user?.uid,
|
||
'email': email,
|
||
'nickname': nickname,
|
||
'created_at': DateTime.now().toIso8601String(),
|
||
});
|
||
|
||
return User(
|
||
id: credential.user!.uid,
|
||
email: email,
|
||
nickname: nickname,
|
||
);
|
||
} catch (e) {
|
||
throw AuthException(e.toString());
|
||
}
|
||
}
|
||
|
||
// 登入
|
||
Future<User> signIn({
|
||
required String email,
|
||
required String password,
|
||
}) async {
|
||
// 實作...
|
||
}
|
||
|
||
// 登出
|
||
Future<void> signOut() async {
|
||
await _firebaseAuth.signOut();
|
||
}
|
||
|
||
// 取得當前用戶
|
||
Stream<User?> get authStateChanges {
|
||
return _firebaseAuth.authStateChanges().map((firebaseUser) {
|
||
if (firebaseUser == null) return null;
|
||
return User(
|
||
id: firebaseUser.uid,
|
||
email: firebaseUser.email!,
|
||
);
|
||
});
|
||
}
|
||
}
|
||
```
|
||
|
||
### 4.2 AI 詞卡生成服務
|
||
|
||
```dart
|
||
// lib/data/services/gemini_service.dart
|
||
class GeminiService {
|
||
static const String _apiKey = 'YOUR_API_KEY';
|
||
static const String _baseUrl = 'https://generativelanguage.googleapis.com';
|
||
|
||
final Dio _dio;
|
||
|
||
GeminiService() : _dio = Dio(BaseOptions(
|
||
baseUrl: _baseUrl,
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
));
|
||
|
||
Future<CardModel> generateCard({
|
||
required String sentence,
|
||
required String targetWord,
|
||
}) async {
|
||
try {
|
||
final prompt = _buildPrompt(sentence, targetWord);
|
||
|
||
final response = await _dio.post(
|
||
'/v1beta/models/gemini-pro:generateContent',
|
||
queryParameters: {'key': _apiKey},
|
||
data: {
|
||
'contents': [{
|
||
'parts': [{
|
||
'text': prompt,
|
||
}]
|
||
}],
|
||
'generationConfig': {
|
||
'temperature': 0.7,
|
||
'maxOutputTokens': 1024,
|
||
},
|
||
},
|
||
);
|
||
|
||
final content = response.data['candidates'][0]['content']['parts'][0]['text'];
|
||
final json = jsonDecode(_extractJson(content));
|
||
|
||
return CardModel.fromJson(json);
|
||
} catch (e) {
|
||
throw GeminiException('Failed to generate card: $e');
|
||
}
|
||
}
|
||
|
||
String _buildPrompt(String sentence, String targetWord) {
|
||
return '''
|
||
你是一個專業的英語教學助手。請根據以下資訊生成詞彙學習卡片。
|
||
|
||
原始句子:$sentence
|
||
目標單字:$targetWord
|
||
|
||
請以純 JSON 格式回應(不要包含其他文字):
|
||
{
|
||
"word": "目標單字",
|
||
"pronunciation": "IPA音標",
|
||
"definition": "繁體中文定義(簡潔明瞭)",
|
||
"partOfSpeech": "詞性(noun/verb/adjective等)",
|
||
"examples": [
|
||
{
|
||
"english": "英文例句1",
|
||
"chinese": "中文翻譯1"
|
||
},
|
||
{
|
||
"english": "英文例句2",
|
||
"chinese": "中文翻譯2"
|
||
}
|
||
],
|
||
"difficulty": "難度等級(beginner/intermediate/advanced)"
|
||
}
|
||
''';
|
||
}
|
||
|
||
String _extractJson(String content) {
|
||
// 提取 JSON 部分
|
||
final start = content.indexOf('{');
|
||
final end = content.lastIndexOf('}') + 1;
|
||
return content.substring(start, end);
|
||
}
|
||
}
|
||
```
|
||
|
||
### 4.3 間隔重複演算法
|
||
|
||
```dart
|
||
// lib/core/algorithms/sm2_algorithm.dart
|
||
class SM2Algorithm {
|
||
static const double _minEasinessFactor = 1.3;
|
||
static const double _defaultEasinessFactor = 2.5;
|
||
|
||
static ReviewResult calculate({
|
||
required int quality, // 1-5
|
||
required double currentEF,
|
||
required int currentInterval,
|
||
required int repetitions,
|
||
}) {
|
||
assert(quality >= 1 && quality <= 5);
|
||
|
||
double newEF = currentEF;
|
||
int newInterval = currentInterval;
|
||
int newRepetitions = repetitions;
|
||
|
||
if (quality < 3) {
|
||
// 回答錯誤,重置
|
||
newInterval = 1;
|
||
newRepetitions = 0;
|
||
} else {
|
||
// 計算新的 EF
|
||
newEF = currentEF + (0.1 - (5 - quality) * (0.08 + (5 - quality) * 0.02));
|
||
newEF = max(_minEasinessFactor, newEF);
|
||
|
||
// 計算新的間隔
|
||
if (repetitions == 0) {
|
||
newInterval = 1;
|
||
} else if (repetitions == 1) {
|
||
newInterval = 6;
|
||
} else {
|
||
newInterval = (currentInterval * newEF).round();
|
||
}
|
||
|
||
newRepetitions++;
|
||
}
|
||
|
||
return ReviewResult(
|
||
easinessFactor: newEF,
|
||
interval: newInterval,
|
||
repetitions: newRepetitions,
|
||
nextReviewDate: DateTime.now().add(Duration(days: newInterval)),
|
||
);
|
||
}
|
||
}
|
||
|
||
class ReviewResult {
|
||
final double easinessFactor;
|
||
final int interval;
|
||
final int repetitions;
|
||
final DateTime nextReviewDate;
|
||
|
||
ReviewResult({
|
||
required this.easinessFactor,
|
||
required this.interval,
|
||
required this.repetitions,
|
||
required this.nextReviewDate,
|
||
});
|
||
}
|
||
```
|
||
|
||
### 4.4 本地存儲服務
|
||
|
||
```dart
|
||
// lib/data/services/storage_service.dart
|
||
class StorageService {
|
||
static const String _cardsBoxName = 'cards';
|
||
static const String _userBoxName = 'user';
|
||
static const String _settingsBoxName = 'settings';
|
||
|
||
late Box<CardModel> _cardsBox;
|
||
late Box _userBox;
|
||
late Box _settingsBox;
|
||
|
||
Future<void> init() async {
|
||
await Hive.initFlutter();
|
||
|
||
// 註冊 Adapters
|
||
Hive.registerAdapter(CardModelAdapter());
|
||
|
||
// 開啟 Boxes
|
||
_cardsBox = await Hive.openBox<CardModel>(_cardsBoxName);
|
||
_userBox = await Hive.openBox(_userBoxName);
|
||
_settingsBox = await Hive.openBox(_settingsBoxName);
|
||
}
|
||
|
||
// 詞卡操作
|
||
Future<void> saveCard(CardModel card) async {
|
||
await _cardsBox.put(card.id, card);
|
||
}
|
||
|
||
List<CardModel> getAllCards() {
|
||
return _cardsBox.values.toList();
|
||
}
|
||
|
||
List<CardModel> getTodayReviewCards() {
|
||
final today = DateTime.now();
|
||
return _cardsBox.values.where((card) {
|
||
return card.nextReviewDate.isBefore(today) ||
|
||
card.nextReviewDate.isAtSameMomentAs(today);
|
||
}).toList();
|
||
}
|
||
|
||
Future<void> updateCard(CardModel card) async {
|
||
await _cardsBox.put(card.id, card);
|
||
}
|
||
|
||
Future<void> deleteCard(String cardId) async {
|
||
await _cardsBox.delete(cardId);
|
||
}
|
||
|
||
// 設定操作
|
||
Future<void> saveSetting(String key, dynamic value) async {
|
||
await _settingsBox.put(key, value);
|
||
}
|
||
|
||
T? getSetting<T>(String key) {
|
||
return _settingsBox.get(key) as T?;
|
||
}
|
||
|
||
// 清除所有資料
|
||
Future<void> clearAll() async {
|
||
await _cardsBox.clear();
|
||
await _userBox.clear();
|
||
await _settingsBox.clear();
|
||
}
|
||
}
|
||
```
|
||
|
||
## 5. 狀態管理
|
||
|
||
### 5.1 Provider 架構
|
||
|
||
```dart
|
||
// lib/presentation/providers/card_provider.dart
|
||
class CardProvider extends ChangeNotifier {
|
||
final CardRepository _repository;
|
||
final GeminiService _geminiService;
|
||
final StorageService _storageService;
|
||
|
||
List<CardModel> _cards = [];
|
||
List<CardModel> _todayReviewCards = [];
|
||
bool _isLoading = false;
|
||
String? _error;
|
||
|
||
CardProvider({
|
||
required CardRepository repository,
|
||
required GeminiService geminiService,
|
||
required StorageService storageService,
|
||
}) : _repository = repository,
|
||
_geminiService = geminiService,
|
||
_storageService = storageService;
|
||
|
||
List<CardModel> get cards => _cards;
|
||
List<CardModel> get todayReviewCards => _todayReviewCards;
|
||
bool get isLoading => _isLoading;
|
||
String? get error => _error;
|
||
|
||
Future<void> generateCard({
|
||
required String sentence,
|
||
required String targetWord,
|
||
}) async {
|
||
_setLoading(true);
|
||
_error = null;
|
||
|
||
try {
|
||
// 1. 呼叫 Gemini API
|
||
final card = await _geminiService.generateCard(
|
||
sentence: sentence,
|
||
targetWord: targetWord,
|
||
);
|
||
|
||
// 2. 儲存到遠端
|
||
await _repository.createCard(card);
|
||
|
||
// 3. 儲存到本地
|
||
await _storageService.saveCard(card);
|
||
|
||
// 4. 更新狀態
|
||
_cards.add(card);
|
||
notifyListeners();
|
||
} catch (e) {
|
||
_error = e.toString();
|
||
notifyListeners();
|
||
} finally {
|
||
_setLoading(false);
|
||
}
|
||
}
|
||
|
||
Future<void> loadTodayReviewCards() async {
|
||
_setLoading(true);
|
||
|
||
try {
|
||
// 優先從本地載入
|
||
_todayReviewCards = _storageService.getTodayReviewCards();
|
||
notifyListeners();
|
||
|
||
// 背景同步遠端資料
|
||
final remoteCards = await _repository.getTodayReviewCards();
|
||
_todayReviewCards = remoteCards;
|
||
notifyListeners();
|
||
} catch (e) {
|
||
_error = e.toString();
|
||
notifyListeners();
|
||
} finally {
|
||
_setLoading(false);
|
||
}
|
||
}
|
||
|
||
void _setLoading(bool value) {
|
||
_isLoading = value;
|
||
notifyListeners();
|
||
}
|
||
}
|
||
```
|
||
|
||
## 6. 網路層設計
|
||
|
||
### 6.1 API 客戶端
|
||
|
||
```dart
|
||
// lib/data/services/api_service.dart
|
||
class ApiService {
|
||
late Dio _dio;
|
||
final SupabaseClient _supabase;
|
||
|
||
ApiService({required SupabaseClient supabase}) : _supabase = supabase {
|
||
_dio = Dio(BaseOptions(
|
||
connectTimeout: const Duration(seconds: 10),
|
||
receiveTimeout: const Duration(seconds: 10),
|
||
));
|
||
|
||
// 加入重試機制
|
||
_dio.interceptors.add(
|
||
RetryInterceptor(
|
||
dio: _dio,
|
||
retries: 3,
|
||
retryDelays: const [
|
||
Duration(seconds: 1),
|
||
Duration(seconds: 2),
|
||
Duration(seconds: 3),
|
||
],
|
||
),
|
||
);
|
||
|
||
// 加入日誌
|
||
if (kDebugMode) {
|
||
_dio.interceptors.add(LogInterceptor(
|
||
requestBody: true,
|
||
responseBody: true,
|
||
));
|
||
}
|
||
}
|
||
|
||
// Supabase 操作封裝
|
||
Future<List<Map<String, dynamic>>> getCards(String userId) async {
|
||
final response = await _supabase
|
||
.from('cards')
|
||
.select()
|
||
.eq('user_id', userId)
|
||
.order('created_at', ascending: false);
|
||
|
||
return List<Map<String, dynamic>>.from(response);
|
||
}
|
||
|
||
Future<void> createCard(Map<String, dynamic> card) async {
|
||
await _supabase.from('cards').insert(card);
|
||
}
|
||
|
||
Future<void> updateCard(String id, Map<String, dynamic> updates) async {
|
||
await _supabase
|
||
.from('cards')
|
||
.update(updates)
|
||
.eq('id', id);
|
||
}
|
||
|
||
Future<void> deleteCard(String id) async {
|
||
await _supabase
|
||
.from('cards')
|
||
.delete()
|
||
.eq('id', id);
|
||
}
|
||
}
|
||
```
|
||
|
||
## 7. 錯誤處理
|
||
|
||
### 7.1 例外定義
|
||
|
||
```dart
|
||
// lib/core/errors/exceptions.dart
|
||
class AppException implements Exception {
|
||
final String message;
|
||
final String? code;
|
||
|
||
AppException(this.message, [this.code]);
|
||
}
|
||
|
||
class AuthException extends AppException {
|
||
AuthException(String message, [String? code]) : super(message, code);
|
||
}
|
||
|
||
class NetworkException extends AppException {
|
||
NetworkException(String message, [String? code]) : super(message, code);
|
||
}
|
||
|
||
class GeminiException extends AppException {
|
||
GeminiException(String message, [String? code]) : super(message, code);
|
||
}
|
||
|
||
class StorageException extends AppException {
|
||
StorageException(String message, [String? code]) : super(message, code);
|
||
}
|
||
```
|
||
|
||
### 7.2 錯誤處理器
|
||
|
||
```dart
|
||
// lib/core/utils/error_handler.dart
|
||
class ErrorHandler {
|
||
static String getMessage(dynamic error) {
|
||
if (error is AppException) {
|
||
return error.message;
|
||
} else if (error is FirebaseAuthException) {
|
||
return _getFirebaseAuthMessage(error.code);
|
||
} else if (error is DioException) {
|
||
return _getDioMessage(error);
|
||
} else {
|
||
return '發生未知錯誤,請稍後再試';
|
||
}
|
||
}
|
||
|
||
static String _getFirebaseAuthMessage(String code) {
|
||
switch (code) {
|
||
case 'email-already-in-use':
|
||
return '此 Email 已被註冊';
|
||
case 'invalid-email':
|
||
return 'Email 格式不正確';
|
||
case 'weak-password':
|
||
return '密碼強度不足';
|
||
case 'user-not-found':
|
||
return '找不到此用戶';
|
||
case 'wrong-password':
|
||
return '密碼錯誤';
|
||
default:
|
||
return '認證失敗,請稍後再試';
|
||
}
|
||
}
|
||
|
||
static String _getDioMessage(DioException error) {
|
||
switch (error.type) {
|
||
case DioExceptionType.connectionTimeout:
|
||
case DioExceptionType.sendTimeout:
|
||
case DioExceptionType.receiveTimeout:
|
||
return '連線逾時,請檢查網路';
|
||
case DioExceptionType.connectionError:
|
||
return '無法連線到伺服器';
|
||
default:
|
||
return '網路錯誤,請稍後再試';
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
## 8. 效能優化
|
||
|
||
### 8.1 圖片快取策略
|
||
|
||
```dart
|
||
// 使用 cached_network_image
|
||
CachedNetworkImage(
|
||
imageUrl: imageUrl,
|
||
placeholder: (context, url) => Shimmer.fromColors(
|
||
baseColor: Colors.grey[300]!,
|
||
highlightColor: Colors.grey[100]!,
|
||
child: Container(
|
||
color: Colors.white,
|
||
),
|
||
),
|
||
errorWidget: (context, url, error) => Icon(Icons.error),
|
||
cacheKey: imageUrl, // 使用 URL 作為快取鍵
|
||
maxHeightDiskCache: 1000,
|
||
maxWidthDiskCache: 1000,
|
||
)
|
||
```
|
||
|
||
### 8.2 列表優化
|
||
|
||
```dart
|
||
// 使用 ListView.builder 避免一次渲染所有項目
|
||
ListView.builder(
|
||
itemCount: cards.length,
|
||
itemBuilder: (context, index) {
|
||
return CardItem(card: cards[index]);
|
||
},
|
||
// 優化捲動效能
|
||
addAutomaticKeepAlives: false,
|
||
addRepaintBoundaries: false,
|
||
cacheExtent: 100,
|
||
)
|
||
```
|
||
|
||
### 8.3 防抖處理
|
||
|
||
```dart
|
||
// lib/core/utils/debouncer.dart
|
||
class Debouncer {
|
||
final int milliseconds;
|
||
Timer? _timer;
|
||
|
||
Debouncer({required this.milliseconds});
|
||
|
||
void run(VoidCallback action) {
|
||
_timer?.cancel();
|
||
_timer = Timer(Duration(milliseconds: milliseconds), action);
|
||
}
|
||
|
||
void dispose() {
|
||
_timer?.cancel();
|
||
}
|
||
}
|
||
|
||
// 使用範例
|
||
final _debouncer = Debouncer(milliseconds: 500);
|
||
|
||
onSearchChanged(String query) {
|
||
_debouncer.run(() {
|
||
// 執行搜尋
|
||
performSearch(query);
|
||
});
|
||
}
|
||
```
|
||
|
||
## 9. 安全性考量
|
||
|
||
### 9.1 API Key 管理
|
||
|
||
```dart
|
||
// 使用環境變數
|
||
// .env 檔案(加入 .gitignore)
|
||
GEMINI_API_KEY=your_api_key_here
|
||
SUPABASE_URL=your_supabase_url
|
||
SUPABASE_ANON_KEY=your_anon_key
|
||
|
||
// 載入環境變數
|
||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||
|
||
Future<void> main() async {
|
||
await dotenv.load();
|
||
runApp(MyApp());
|
||
}
|
||
|
||
// 使用
|
||
final apiKey = dotenv.env['GEMINI_API_KEY']!;
|
||
```
|
||
|
||
### 9.2 資料驗證
|
||
|
||
```dart
|
||
// lib/core/utils/validators.dart
|
||
class Validators {
|
||
static String? email(String? value) {
|
||
if (value == null || value.isEmpty) {
|
||
return '請輸入 Email';
|
||
}
|
||
final regex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$');
|
||
if (!regex.hasMatch(value)) {
|
||
return 'Email 格式不正確';
|
||
}
|
||
return null;
|
||
}
|
||
|
||
static String? password(String? value) {
|
||
if (value == null || value.isEmpty) {
|
||
return '請輸入密碼';
|
||
}
|
||
if (value.length < 8) {
|
||
return '密碼至少需要 8 個字元';
|
||
}
|
||
return null;
|
||
}
|
||
|
||
static String? sentence(String? value) {
|
||
if (value == null || value.isEmpty) {
|
||
return '請輸入句子';
|
||
}
|
||
if (value.length > 200) {
|
||
return '句子不能超過 200 個字元';
|
||
}
|
||
return null;
|
||
}
|
||
}
|
||
```
|
||
|
||
## 10. 測試策略
|
||
|
||
### 10.1 單元測試
|
||
|
||
```dart
|
||
// test/algorithms/sm2_algorithm_test.dart
|
||
void main() {
|
||
group('SM2Algorithm', () {
|
||
test('should reset interval when quality < 3', () {
|
||
final result = SM2Algorithm.calculate(
|
||
quality: 2,
|
||
currentEF: 2.5,
|
||
currentInterval: 10,
|
||
repetitions: 5,
|
||
);
|
||
|
||
expect(result.interval, equals(1));
|
||
expect(result.repetitions, equals(0));
|
||
});
|
||
|
||
test('should increase interval for good performance', () {
|
||
final result = SM2Algorithm.calculate(
|
||
quality: 4,
|
||
currentEF: 2.5,
|
||
currentInterval: 6,
|
||
repetitions: 2,
|
||
);
|
||
|
||
expect(result.interval, greaterThan(6));
|
||
expect(result.repetitions, equals(3));
|
||
});
|
||
});
|
||
}
|
||
```
|
||
|
||
### 10.2 Widget 測試
|
||
|
||
```dart
|
||
// test/widgets/card_item_test.dart
|
||
void main() {
|
||
testWidgets('CardItem displays word and definition', (tester) async {
|
||
final card = CardModel(
|
||
id: '1',
|
||
word: 'test',
|
||
definition: '測試',
|
||
);
|
||
|
||
await tester.pumpWidget(
|
||
MaterialApp(
|
||
home: Scaffold(
|
||
body: CardItem(card: card),
|
||
),
|
||
),
|
||
);
|
||
|
||
expect(find.text('test'), findsOneWidget);
|
||
expect(find.text('測試'), findsOneWidget);
|
||
});
|
||
}
|
||
```
|
||
|
||
## 11. 部署配置
|
||
|
||
### 11.1 環境配置
|
||
|
||
```dart
|
||
// lib/config/environment.dart
|
||
class Environment {
|
||
static const String development = 'development';
|
||
static const String production = 'production';
|
||
|
||
static String get current =>
|
||
const String.fromEnvironment('ENV', defaultValue: development);
|
||
|
||
static bool get isDevelopment => current == development;
|
||
static bool get isProduction => current == production;
|
||
|
||
static String get apiUrl {
|
||
switch (current) {
|
||
case production:
|
||
return 'https://api.linguaforge.com';
|
||
default:
|
||
return 'https://dev-api.linguaforge.com';
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
### 11.2 建置腳本
|
||
|
||
```bash
|
||
# scripts/build.sh
|
||
#!/bin/bash
|
||
|
||
# 開發版
|
||
flutter build apk --debug --dart-define=ENV=development
|
||
|
||
# 生產版
|
||
flutter build apk --release --dart-define=ENV=production
|
||
flutter build ios --release --dart-define=ENV=production
|
||
|
||
# 混淆
|
||
flutter build apk --release --obfuscate --split-debug-info=./debug-info
|
||
```
|
||
|
||
## 12. 監控與分析
|
||
|
||
### 12.1 Firebase 設置
|
||
|
||
```dart
|
||
// lib/main.dart
|
||
void main() async {
|
||
WidgetsFlutterBinding.ensureInitialized();
|
||
|
||
// Firebase 初始化
|
||
await Firebase.initializeApp(
|
||
options: DefaultFirebaseOptions.currentPlatform,
|
||
);
|
||
|
||
// Crashlytics 設置
|
||
FlutterError.onError = FirebaseCrashlytics.instance.recordFlutterFatalError;
|
||
|
||
// 捕捉非同步錯誤
|
||
PlatformDispatcher.instance.onError = (error, stack) {
|
||
FirebaseCrashlytics.instance.recordError(error, stack, fatal: true);
|
||
return true;
|
||
};
|
||
|
||
runApp(MyApp());
|
||
}
|
||
```
|
||
|
||
### 12.2 效能追蹤
|
||
|
||
```dart
|
||
// 追蹤 API 呼叫
|
||
Future<T> trackPerformance<T>(
|
||
String name,
|
||
Future<T> Function() operation,
|
||
) async {
|
||
final trace = FirebasePerformance.instance.newTrace(name);
|
||
await trace.start();
|
||
|
||
try {
|
||
final result = await operation();
|
||
trace.setMetric('success', 1);
|
||
return result;
|
||
} catch (e) {
|
||
trace.setMetric('error', 1);
|
||
rethrow;
|
||
} finally {
|
||
await trace.stop();
|
||
}
|
||
}
|
||
|
||
// 使用
|
||
final card = await trackPerformance(
|
||
'generate_card',
|
||
() => _geminiService.generateCard(sentence: sentence, targetWord: word),
|
||
);
|
||
``` |