待辦任務APP

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

GitHub|因為一堆API key還沒處理先暫不打開】

待辦任務app|目前是部署好了但還沒解決Proxy Server問題】

(18周還要考試所以之後才會改完Orz)

身為一個規劃控下載過幾乎App store上所有Todo List App,但沒有一款App完全符合個人需求,因此決定開發一款適合自己規劃習慣的Todo App。

需求分析

  1. 多裝置同步
  2. 待辦任務可以不寫時間
  3. 在不寫時間的同時又可以清楚優先順序
  4. 免費
  5. 具有月曆顯示
  6. 量化完成情況(圖表、數字)
  • 使用 Riverpod 作為應用狀態管理工具。
  • 利用 Firebase 的實時資料庫(Realtime Database)實現資料的雲端同步功能,保證資料的一致性和即時性。
  • 使用 LinearProgressIndicator 顯示下載進度條,在下載資料或檔案時提供視覺顯示,使用者可以即時了解進度狀況,提升使用體驗。
  • 整合 Google Translate API,提供多語言介面。
  • 利用 showDialog 或 showSnackBar 來提示用戶登入失敗原因。
  • 利用 SharedPreferences 儲存用戶的待辦事項,實現輕量級的本地資料持久化。

#開發註冊功能FavQs API問題

網頁開發常見之 CORS 錯誤原因與 Express 解決辦法

解法: 在local端創建代理伺服器,繞過CORS限制

  • 初始化一個新的Node.js項目:
npm init -y
  • 安装Express和CORS
npm install express cors axios
  • 新增 server.js
const express = require('express');
const cors = require('cors');
const axios = require('axios');
const app = express();

app.use(cors());
app.use(express.json());

app.post('/api/users', async (req, res) => {
try {
const response = await axios.post('https://favqs.com/api/users', req.body, {
headers: {
'Content-Type': 'application/json',
'Authorization': 'Token token="YOUR_FAVQS_API_KEY"',
},
});
res.json(response.data);
} catch (error) {
res.status(error.response.status).json(error.response.data);
}
});

app.post('/api/session', async (req, res) => {
try {
const response = await axios.post('https://favqs.com/api/session', req.body, {
headers: {
'Content-Type': 'application/json',
'Authorization': 'Token token="YOUR_FAVQS_API_KEY"',
},
});
res.json(response.data);
} catch (error) {
res.status(error.response.status).json(error.response.data);
}
});

const PORT = process.env.PORT || 5000;
app.listen(PORT, () => {
console.log(`Server is running on port ${PORT}`);
});
  • 啟動代理服務
node server.js

#Flutter Riverpod

一些參考: Flutter Riverpod 輕鬆學,簡單處理狀態管理!

//tasks_screen.dart
//todoListProvider 提供 TodoList 狀態管理器,供 Flutter Riverpod 使用。
import 'package:flutter_riverpod/flutter_riverpod.dart';
class TasksScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final tasks = ref.watch(todoListProvider);
  1. TodoList 類別
  • 繼承自 StateNotifier<List<Todo>>,管理 Todo 類型的列表。
  • 構造函數中調用了 _loadTodos_loadTodosFromFirestore 方法來載入本地和雲端的待辦事項。
  • 使用 uuid 生成唯一的待辦事項 ID。

2. 新增、拖曳、編輯、移除

  • add 方法用來新增待辦事項,創建新 Todo 對象,並將其添加到狀態列表中,同時保存到本地和 Firestore 中。
  • toggleCompletion 方法根據 ID 切換待辦事項的完成狀態,並更新 Firestore 中的對應記錄。
  • edit 方法用來編輯指定 ID 的待辦事項,並更新 Firestore 中的對應記錄。
  • remove 方法根據 ID 刪除待辦事項,並從 Firestore 中刪除對應記錄。

3. 更新優先級

  • updatePriority 方法用來更新指定 ID 的待辦事項的優先級,並更新 Firestore 中的對應記錄。

4. 從 Firestore 載入待辦事項

  • _loadTodosFromFirestore
  • _addTodoToFirestore
  • _updateTodoInFirestore
  • _deleteTodoFromFirestore
  • _updateTodoPriorityInFirestore

5. 本地儲存操作

  • _loadTodos 方法從本地 SharedPreferences 載入待辦事項。
  • _saveTodos 方法將待辦事項保存到本地 SharedPreferences。

#雲端同步初始化 Firebase:

//在 main.dart 中初始化 Firebase。
import 'package:firebase_core/firebase_core.dart';
import 'firebase_options.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
try {
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
print('Firebase initialized successfully');
} catch (e) {
print('Error initializing Firebase: $e');
}
runApp(ProviderScope(child: MyApp()));
}

1. 雲端儲存和同步:

在 home_screen.dart 中使用 Firestore 來儲存和同步任務。
使用 StreamBuilder 來實時監聽 Firestore 中的數據變化,並更新 UI。
登出:

提供登出功能,清除使用者的登入狀態,並導航回登入頁面。

dart pub global activate flutterfire_cli

# 部署到Firebase

先記錄一些問題~ flutter版本、git資料夾

firebase init
Hosting: Configure files for Firebase Hosting and (optionally) set up GitHub Action deploys
What do you want to use as your public directory? build/web
  1. 點一點然後Push
  2. GitHub Actions看是否有成功
  3. 去Firebase Console看網址

拖曳待辦事項

創建任務

  • 使用 showModalBottomSheet 函數,彈出一個從底部滑入的對話框,並覆蓋在當前的視圖之上。滑入效果是透過 showModalBottomSheet 提供的內置動畫來實現的。

完成任務

雲端同步

每日一詞

  • 抓不到資料,比方網路有問題時,畫面上顯示錯誤資訊。
  • 使用LinearProgressIndicator,在抓資料時顯示資料下載中(如下圖)
final poetryDataProvider = FutureProvider<PoetryData>((ref) async {
final response =
await http.get(Uri.parse('https://v2.jinrishici.com/one.json'));

if (response.statusCode == 200) {
var data = json.decode(response.body);
return PoetryData.fromJson(data['data']);
} else {
throw Exception('Failed to load poetry');
}
});
  • 將 JSON 轉換成自訂型別。轉換詩詞資料

class PoetryData {
final String id;
final String content;
final int popularity;
final Origin origin;

PoetryData({
required this.id,
required this.content,
required this.popularity,
required this.origin,
});

factory PoetryData.fromJson(Map<String, dynamic> json) {
return PoetryData(
id: json['id'],
content: json['content'],
popularity: json['popularity'],
origin: Origin.fromJson(json['origin']),
);
}
}

class Origin {
final String title;
final String dynasty;
final String author;
final List<String> content;
final String? translate;

Origin({
required this.title,
required this.dynasty,
required this.author,
required this.content,
this.translate,
});

factory Origin.fromJson(Map<String, dynamic> json) {
return Origin(
title: json['title'],
dynasty: json['dynasty'],
author: json['author'],
content: List<String>.from(json['content']),
translate: json['translate'],
);
}
}
  • 點選項目可到下一頁顯示 detail

class PoetryDetailScreen extends StatelessWidget {
final PoetryData poetryData;

PoetryDetailScreen({required this.poetryData});

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(poetryData.origin.title),
),
backgroundColor: Color(0xFFF0F2F3),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: EdgeInsets.symmetric(vertical: 8, horizontal: 16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(10),
),
child: Text(
poetryData.origin.title,
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
),
),
SizedBox(height: 8),
Container(
padding: EdgeInsets.symmetric(vertical: 8, horizontal: 16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(10),
),
child: Text(
'作者: ${poetryData.origin.author} (${poetryData.origin.dynasty})',
style: TextStyle(fontSize: 18, fontStyle: FontStyle.italic),
),
),
SizedBox(height: 16),
Container(
padding: EdgeInsets.symmetric(vertical: 8, horizontal: 16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(10),
),
child: Text(
poetryData.content,
style: TextStyle(fontSize: 16),
),
),
SizedBox(height: 16),
Container(
padding: EdgeInsets.symmetric(vertical: 8, horizontal: 8),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(10),
),
child: Column(
children: [
Text(
'原文',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
for (var line in poetryData.origin.content)
Text(
line,
style: TextStyle(fontSize: 16),
),
],
),
),
],
),
),
);
}
}
  • 下拉更新功能,使用 RefreshIndicator。
    return Scaffold(
backgroundColor: Color(0xFFF0F2F3),
body: RefreshIndicator(
onRefresh: () async {
ref.refresh(poetryDataProvider);
},

# Google-Translate-API


Future<String> translateToTraditional(String text) async {
final apiKey = Secrets.googleApiKey;
final url =
'https://translation.googleapis.com/language/translate/v2?key=$apiKey';

final response = await http.post(
Uri.parse(url),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'q': text,
'source': 'zh-CN',
'target': 'zh-TW',
'format': 'text',
}),
);

if (response.statusCode == 200) {
var data = json.decode(response.body);
return data['data']['translations'][0]['translatedText'];
} else {
throw Exception('Failed to translate text');
}
}

登入 註冊

  • 使用 FavQs API 開發註冊登入功能

Future<void> _register() async {
print('Attempting to register...');
try {
var dio = Dio();
final response = await dio.post(
'http://localhost:5000/api/users',
options: Options(
headers: {
'Content-Type': 'application/json',
},
),
data: {
'user': {
'login': _username,
'email': _email,
'password': _password,
},
},
);

print('Response status: ${response.statusCode}');
print('Response headers: ${response.headers}');
print('Response data: ${response.data}');

if (response.statusCode == 200) {
_showSuccessDialog('註冊成功');
} else {
setState(() {
_errorMessage = '註冊失敗: ${response.data}';
});
}
} catch (error) {
if (error is DioError) {
setState(() {
_errorMessage = 'Dio error: ${error.message}';
});
print('Dio error: ${error.message}');
print('Error type: ${error.type}');
if (error.response != null) {
print('Error response: ${error.response?.data}');
}
} else {
setState(() {
_errorMessage = 'An error occurred: $error';
});
}
}
}
  • 寫在Proxy Server內的檔案
//server.js
const express = require('express');
const cors = require('cors');
const axios = require('axios');
const app = express();

app.use(cors());
app.use(express.json());

app.post('/api/users', async (req, res) => {
try {
const response = await axios.post('https://favqs.com/api/users', req.body, {
headers: {
'Content-Type': 'application/json',
'Authorization': 'Token token={YOUR-FavQs-TOKEN}',//使用 FavQs API 開發註冊登入功能
},
});
res.json(response.data);
} catch (error) {
res.status(error.response.status).json(error.response.data);
}
});

app.post('/api/session', async (req, res) => {
try {
const response = await axios.post('https://favqs.com/api/session', req.body, {
headers: {
'Content-Type': 'application/json',
'Authorization': 'Token token={YOUR-FavQs-TOKEN}',
},
});
res.json(response.data);
} catch (error) {
res.status(error.response.status).json(error.response.data);
}
});

const PORT = process.env.PORT || 5000;
app.listen(PORT, () => {
console.log(`Server is running on port ${PORT}`);
});
  • 使用 showDialog 或 showSnackBar 顯示登入失敗
void _showSuccessDialog(String message) {
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: Text('註冊成功'),
content: Text(message),
actions: <Widget>[
TextButton(
child: Text('OK'),
onPressed: () {
Navigator.of(context).pop();
Navigator.pushReplacementNamed(context, '/login');
},
),
],
);
},
);
}

--

--