[final] 單字本

Wei
海大 SwiftUI iOS / Flutter App 程式設計
24 min readJun 12, 2024

github

https://github.com/currybanfan/flutter_final_project

註冊登入系統

註冊&驗證

  • 輸入email、密碼、確認密碼
  • 透過 supabase API 進行註冊
  • 透過 email 中的 link 來驗證帳號

使用SupabaseProvider進行資料庫相關的全局控制


// SupabaseProvider 類,管理用戶身份驗證和數據庫操作
class SupabaseProvider extends ChangeNotifier {
// Supabase 客戶端
final SupabaseClient _supabaseClient;
// 當前用戶
User? _currentUser;
// session 過期時間
DateTime? _expiryDate;
// 計時器,用於自動登出
Timer? _authTimer;
// 是否為訪客
bool _isGuest = false; // 新增的變數

// 構造函數,初始化 Supabase 客戶端並自動登入
SupabaseProvider(String url, String key)
: _supabaseClient = SupabaseClient(
url,
key,
authOptions: AuthClientOptions(
pkceAsyncStorage: SecureStorage(),
),
) {
_autoSignIn();
_supabaseClient.auth.onAuthStateChange.listen((data) {
// 監聽身份驗證狀態變化
_currentUser = data.session?.user;
notifyListeners();
});
}

// 獲取 Supabase 客戶端
SupabaseClient get client => _supabaseClient;

// 判斷用戶是否已登入
bool get isLoggedIn => _currentUser != null;
// 判斷是否為訪客
bool get isGuest => _isGuest;

// 訪客登入方法
void guestSignIn() {
_isGuest = true;
notifyListeners();
}

// 用戶註冊方法
Future<void> signUp(String email, String password) async {
try {
await _supabaseClient.auth.signUp(email: email, password: password);
} catch (error) {
print('error: ${error.toString()}');
rethrow;
}
}
}

登入

  • 使用 supabase api 進行登入
  • 若是帳號或密碼錯誤,彈出錯誤訊息
  • 使用自定義的TopSnackBar彈出訊息

signIn API

// 用戶登入方法
Future<void> signIn(String email, String password) async {
try {
final response = await _supabaseClient.auth
.signInWithPassword(email: email, password: password);

if (response.user == null) {
throw ('找不到使用者');
}

_currentUser = response.user;
_expiryDate =
DateTime.now().add(Duration(seconds: response.session!.expiresIn!));

_autoLogout();
notifyListeners();

// 將 session 保存到本地存儲
final prefs = await SharedPreferences.getInstance();
await prefs.setString('session', jsonEncode(response.session!.toJson()));
} catch (error) {
rethrow;
}
}

TopSnackBar : 自定義位置、動畫

void showTopSnackBar(BuildContext context, String message, SnackBarType type) {
// 獲取當前的 Overlay 和主題
final overlay = Overlay.of(context);
final theme = Theme.of(context);

// ...

// 創建 OverlayEntry,用於顯示 Snackbar
final overlayEntry = OverlayEntry(
builder: (context) => Positioned(
// 設定 Snackbar 的位置
top: 10.0,
left: MediaQuery.of(context).size.width * 0.1,
width: MediaQuery.of(context).size.width * 0.8,
child: SlideTransition(
// 設定位移動畫
position: Tween<Offset>(
begin: const Offset(0, -1),
end: const Offset(0, 0),
).animate(CurvedAnimation(
parent: AnimationController(
duration: const Duration(milliseconds: 200),
vsync: ScaffoldMessenger.of(context),
)..forward(),
curve: Curves.easeInOut,
)),

// ...

),
),
);

// 插入 OverlayEntry
overlay.insert(overlayEntry);

// 設置 Snackbar 顯示 2 秒後移除
Future.delayed(const Duration(seconds: 2), () {
overlayEntry.remove();
});
}

訪客登入

  • 可以不使用帳號密碼進行登入
  • 筆記頁面、儲存筆記相關的功能會不可使用

自動登入

  • SupabaseProvider 建構的時候會自動調用 _autoSignIn 方法來嘗試自動登入

_autoSignIn

// 自動登入方法,從本地存儲恢復 session
Future<void> _autoSignIn() async {
// 從本地存儲中讀取之前保存的 session JSON 字符串
final prefs = await SharedPreferences.getInstance();
// 使用 Supabase 提供的 recoverSession 方法恢復用戶的會話
final sessionJson = prefs.getString('session');

if (sessionJson != null) {
try {
// 使用 session JSON 恢復用戶
final response = await _supabaseClient.auth.recoverSession(sessionJson);
// 如果恢復會話成功,將恢復的用戶設置為當前用戶
_currentUser = response.user;
// 設置會話的過期時間
_expiryDate =
DateTime.now().add(Duration(seconds: response.session!.expiresIn!));
// 確保會話過期時自動登出
_autoLogout();
notifyListeners();
} catch (error) {
rethrow;
}
}
}

重新登入

  • 回到登入頁面
  • 清除資訊

signOut

// 用戶登出方法
Future<void> signOut() async {
await _supabaseClient.auth.signOut();
_currentUser = null;
_expiryDate = null;
_isGuest = false;

if (_authTimer != null) {
_authTimer!.cancel();
_authTimer = null;
}

// 清除本地存儲的 session
final prefs = await SharedPreferences.getInstance();
prefs.remove('session');
notifyListeners();
}

佳句API

  • 每次載入頁面都會透過 api 請求隨機呈現一句佳句

API 請求

Future<String> fetchRandomQuote() async {
final response = await http.get(Uri.parse('https://type.fit/api/quotes'));

if (response.statusCode == 200) {
// 解析響應 JSON 並隨機選擇一條佳句
List<dynamic> quotes = json.decode(response.body);
final randomIndex = Random().nextInt(quotes.length);
return quotes[randomIndex]['text'] ?? 'No quote available';
} else {
// 如果請求失敗,拋出異常
throw ('載入佳句失敗');
}
}

單字頁面

  • 使用國高中英文單字API

https://raw.githubusercontent.com/AppPeterPan/TaiwanSchoolEnglishVocabulary/main/國一.json

  • 使用 FutureBuilder 搭配ListView呈現,載入時顯示CircularProgressIndicator ,錯誤時呈現錯誤訊息
  • 使用VocabularyProvider 進行單字相關的全局控制
  • 使用ButtonsTabBar 可以切換不同的單字欄位,發送不同的 api 請求
  • 可以使用搜尋框來搜尋單字

錯誤訊息

VocabularyProvider,包含調用 API 獲取單字

class VocabularyProvider extends ChangeNotifier {
// SupabaseProvider 的實例,用於訪問和管理用戶數據
final SupabaseProvider _supabaseProvider;
// 詞彙地圖,根據級別存儲詞彙列表
final Map<String, List<VocabularyEntry>> _vocabularyMap = {};
// 可用的級別列表
final List<String> levels = [
'國一',
'國二',
'國三',
'1級',
'2級',
'3級',
'4級',
'5級',
'6級',
'筆記'
];

// 構造函數,初始化 SupabaseProvider
VocabularyProvider(this._supabaseProvider);

// 獲取級別列表的方法
List<String> getLevels() {
return levels;
}

// 獲取指定級別的詞彙列表
Future<List<VocabularyEntry>?> getVocabulary(String level) async {
try {
if (level == '筆記') {
// 如果級別為 '筆記',從 SupabaseProvider 獲取筆記
var notes = await _supabaseProvider.getNotes();
return notes.map((note) => note.vocabularyEntry).toList();
} else {
// 否則從遠端服務器獲取詞彙數據
await fetchVocabulary(level);
return _vocabularyMap[level];
}
} catch (e) {
rethrow;
}
}

// 從遠端服務器獲取指定級別的詞彙數據
Future<void> fetchVocabulary(String level) async {
if (!_vocabularyMap.containsKey(level)) {
// 構建請求 URL
var url = Uri.parse(
'https://raw.githubusercontent.com/AppPeterPan/TaiwanSchoolEnglishVocabulary/main/$level.json');
var response = await http.get(url);

if (response.statusCode == 200) {
// 解析響應 JSON 並轉換為 VocabularyEntry 列表
List<dynamic> jsonResponse = json.decode(response.body);
List<VocabularyEntry> vocabularyList =
jsonResponse.map((data) => VocabularyEntry.fromJson(data)).toList();
// 將詞彙列表存儲到 _vocabularyMap 中
_vocabularyMap[level] = vocabularyList;
notifyListeners();
} else {
throw Exception('Failed to load vocabulary for $level');
}
}
}

// ...

}

FutureBuilder

FutureBuilder<List<VocabularyEntry>?>(
future: provider.getVocabulary(level), // 獲取單字
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
} else if (snapshot.hasError) {
// 處裡錯誤
} else if (!snapshot.hasData || snapshot.data!.isEmpty) {
// 處裡錯誤
} else {
var vocabularyList = snapshot.data ?? [];

// ...

// 顯示單字列表
return ListView.separated(
itemCount: vocabularyList.length,
separatorBuilder: (context, index) => const Divider(),
itemBuilder: (context, index) {
// ...
},
);
}
},
),

單字卡

  • 可以透過 flutter_tts model 唸出單字
  • 如果有登入,可以儲存單字到資料庫中
  • 如果單字已經在資料庫中,則可以從資料庫中刪除單字

透 supabase API 儲存或刪除單字

// 保存筆記方法
Future<void> saveNote(VocabularyEntry? entry) async {
final userId = _currentUser?.id;

if (userId != null && entry != null) {
try {
// 將定義列表轉換為 JSON 字符串
final definitionsJson =
jsonEncode(entry.definitions.map((e) => e.toJson()).toList());

await _supabaseClient.from('notes').insert({
'user_id': userId,
'word': entry.word,
'definitions': definitionsJson,
'letter_count': entry.letterCount,
});
} catch (error) {
print(error);

if (error is PostgrestException) {
switch (error.code) {
case '23505':
throw ('單字已存在');
default:
throw ('儲存失敗');
}
}
throw ('儲存失敗');
}
} else {
throw ('無效的用戶或單字');
}
}

// 刪除筆記方法
Future<void> deleteNote(VocabularyEntry? entry) async {
final userId = _currentUser?.id;

if (userId != null && entry != null) {
try {
await _supabaseClient
.from('notes')
.delete()
.eq('user_id', userId)
.eq('word', entry.word);
} catch (error) {
if (error is PostgrestException) {
throw ('刪除失敗');
}
throw ('刪除失敗');
}
} else {
throw ('無效的用戶或單字');
}
}

筆記

  • 使用 supabase API 獲取單字
  • 使用 FutureBuilder 搭配ListView呈現,載入時顯示CircularProgressIndicator,如果未登入顯示 '使用者未登入'
  • 可以使用搜尋框來搜尋單字

測驗

填充題測驗

  • 使用CustomCheckBoxGroup 實現可以多選的 box ,如果沒登入則筆記項目不可以選
  • 根據字數提示輸入文字,當達到字數上限會自動判別是否正確,並呈現動畫
  • 使用 button 直接呈現答案,或是直接跳下一題

透過vocabularyProvider 載入題目

Future<void> _nextQuestion() async {
final entry = await _loadRandomEntry();
setState(() {
_randomEntry = entry;
_showAnimation = _isError = false;
_controller.clear();
_onTextChanged(); // 手動調用 _onTextChanged 更新顯示
});
}

Future<VocabularyEntry?> _loadRandomEntry() async {
return await vocabularyProvider.loadRandomEntry(levels);
}

在輸入時根據剩餘字數更新顯示文字,當輸入文字達到題目字數時,判斷是否正確

void _onTextChanged() {
if (_isLoadingNextEntry) return;
setState(() {
_updateDisplayedText(_controller.text);
if (_controller.text.length == _randomEntry?.letterCount) {
// 判斷輸入是否正確
var isCorrect = _controller.text == _randomEntry?.word;
_showAnimation = isCorrect;
_isError = !isCorrect;
} else {
_isError = false;
_showAnimation = false;
}
if (_showAnimation) {
// 如果正確,延遲一秒鐘加載下一個問題
_isLoadingNextEntry = true;
Future.delayed(const Duration(seconds: 1), () {
_nextQuestion();
_isLoadingNextEntry = false;
});
}
});
}

// 更新顯示的文本
void _updateDisplayedText(String text) {
// 用已輸入的文字加上 _ 表示剩餘字數
int inputLetterCount = text.length;
int remainingBottomLine =
(_randomEntry?.letterCount ?? 0) - inputLetterCount;
String blankField = '';

for (int i = 0; i < remainingBottomLine; i++) {
blankField += '_ ';
}

_displayedText = text + blankField;
}

正確 / 錯誤動畫

AnimatedDefaultTextStyle(
duration: const Duration(milliseconds: 100),
style: theme.textTheme.bodyLarge!.copyWith(
color: _isError
? theme.colorScheme.error
: _showAnimation
? Colors.green
: theme.colorScheme.onSurface,
),
child: Align(
child: Text(
_displayedText,
),
),
),

選擇題測驗

  • 使用CustomCheckBoxGroup 實現可以多選的 box ,如果沒登入則筆記項目不可以選
  • 隨意選擇選項,當按下確認按鈕後會判別答案是否正確,並呈現動畫
  • 使用 button 直接呈現答案

透過vocabularyProvider 載入題目

Future<void> _nextQuestion() async {
final entries = await _loadRandomEntries();
final randomIndex = Random().nextInt(entries.length);
final entry = entries[randomIndex];
final choices = entries.map((e) => e.word).toList();

setState(() {
_randomEntry = entry;
_selectedAnswer = null;
_choices = choices;
});
}

// 從指定級別中加載隨機詞彙條目
Future<List<VocabularyEntry>> _loadRandomEntries() async {
final entries = <VocabularyEntry>[];

while (entries.length < 4) {
final randomEntry = await vocabularyProvider.loadRandomEntry(levels);
if (!entries.contains(randomEntry)) {
entries.add(randomEntry);
}
}

return entries;
}

按下確認按鈕之後確認選擇是否正確,並間隔1秒來呈現動畫

void _checkAnswer() {
if (_selectedAnswer == null || _isLoadingNextEntry) return;
setState(() {
_isLoadingNextEntry = true;
Future.delayed(const Duration(seconds: 1), () {
_nextQuestion();
_isLoadingNextEntry = false;
});
});
}

使用GestureDetectorAnimatedContainer包覆每個選擇框來實現點擊和動畫

..._choices.map((choice) => GestureDetector(
// GestureDetector 用於偵測點擊事件
onTap: () {
// 當選項被點擊時,更新 _selectedAnswer 的值並觸發重建
setState(() {
_selectedAnswer = choice;
});
},
// AnimatedContainer 用於創建帶有動畫效果的容器
child: AnimatedContainer(
// 動畫持續時間為 300 毫秒
duration: const Duration(milliseconds: 300),
// 設置容器的裝飾
decoration: BoxDecoration(
// 設置邊框顏色
border: Border.all(
// 如果正在加載下一個問題,根據答案是否正確設置顏色
color: _isLoadingNextEntry
? (choice == _randomEntry?.word
? Colors.green // 答對了顯示綠色
: (_selectedAnswer == choice
? theme
.colorScheme.error // 選擇錯誤答案顯示紅色
: theme.colorScheme
.onSurface)) // 其他選項顯示默認顏色
: (_selectedAnswer == choice
? theme.colorScheme.primary // 當前選擇顯示主要顏色
: theme
.colorScheme.onSurface), // 其他選項顯示默認顏色
),
// 設置圓角
borderRadius: BorderRadius.circular(8.0),
// 設置背景顏色
color: _isLoadingNextEntry
? (choice == _randomEntry?.word
? Colors.green
.withOpacity(0.3) // 答對了顯示半透明綠色
: (_selectedAnswer == choice
? theme.colorScheme.error
.withOpacity(0.3) // 錯誤選擇顯示半透明紅色
: theme
.colorScheme.surface)) // 其他選項顯示默認顏色
: (_selectedAnswer == choice
? theme.colorScheme.primary
.withOpacity(0.1) // 當前選擇顯示半透明主要顏色
: theme.colorScheme.surface), // 其他選項顯示默認顏色
),
// 設置內邊距
padding: const EdgeInsets.symmetric(
vertical: 10.0, horizontal: 10.0),
// 設置外邊距
margin: const EdgeInsets.symmetric(
vertical: 5.0, horizontal: 15.0),
// 使用 Row 佈局內部內容
child: Row(
children: [
// 選項單選按鈕
Radio<String>(
value: choice,
groupValue: _selectedAnswer,
onChanged: (value) {
// 當單選按鈕狀態改變時,更新 _selectedAnswer 的值並觸發重建
setState(() {
_selectedAnswer = value;
});
},
),
// Expanded 用於佔據剩餘空間顯示選項文本
Expanded(
child: Text(
choice,
style: theme.textTheme.bodyLarge,
),
),
],
),
),
)),

--

--