dramaling-vocab-learning/00_starter/old/mvp-technical-spec.md

22 KiB
Raw Blame History

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 前端技術棧

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 後端服務

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 認證模組

// 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 詞卡生成服務

// 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 間隔重複演算法

// 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 本地存儲服務

// 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 架構

// 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 客戶端

// 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 例外定義

// 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 錯誤處理器

// 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 圖片快取策略

// 使用 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 列表優化

// 使用 ListView.builder 避免一次渲染所有項目
ListView.builder(
  itemCount: cards.length,
  itemBuilder: (context, index) {
    return CardItem(card: cards[index]);
  },
  // 優化捲動效能
  addAutomaticKeepAlives: false,
  addRepaintBoundaries: false,
  cacheExtent: 100,
)

8.3 防抖處理

// 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 管理

// 使用環境變數
// .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 資料驗證

// 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 單元測試

// 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 測試

// 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 環境配置

// 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 建置腳本

# 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 設置

// 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 效能追蹤

// 追蹤 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),
);