Flutter 跨平台 App 程式設計入門】#04 期末app 查機票換匯app

cy
海大 SwiftUI iOS / Flutter App 程式設計
27 min readJun 5, 2024

github連結:https://github.com/cy1227/travel-assistent-app

簡介:這是一個查詢機票以及換匯的旅遊助手app,使用者可以查詢想要的去的國家以及想換的貨幣

(以下畫面有些filter拼錯了還沒改到 後面已經全部改成中文版app)

功能需求:

  • 串接網路上的 API 抓取 JSON 資料後以 ListView 或 GridView 顯示,點選項目可到下一頁顯示 detail,至少使用到兩個 API。
  1. api 1: skyscanner round trip api
    這個api免費的帳號一個月只能使用100次,後來測試的時候一直跳額度不夠就乾脆花錢了🫠
    var url = Uri.parse(
'https://sky-scanner3.p.rapidapi.com/flights/search-roundtrip?'+
'fromEntityId=$departure&toEntityId=$destination&departDate=$departureDate&returnDate=$returnDate&adults=$passengers&currency=TWD&locale=zh-TW&stops=direct');
var response = await http.get(
url,
headers: <String, String>{
'X-RapidAPI-Key': 'f5c594f263msh847a1985e13d7d9p1bd507jsn0427bf3d7428',
'X-RapidAPI-Host': 'sky-scanner3.p.rapidapi.com',
},
);

這個api有很多param可以填寫 例如出發地、目的地、出發日期、回程日期、顯示的語言、貨幣、人數、是否直飛等等,方便起見部分參數直接設定好沒有提供給使用者選擇。

2. api 2: skyscanner auto complete api

    final url = Uri.parse(
'https://sky-scanner3.p.rapidapi.com/flights/auto-complete?query=$query&locale=zh-TW');
final response = await http.get(url, headers: {
'X-RapidAPI-Key': 'f5c594f263msh847a1985e13d7d9p1bd507jsn0427bf3d7428',
'X-RapidAPI-Host': 'sky-scanner3.p.rapidapi.com'
});

因為每個使用者輸入的目的地形式可能會不同,所以多使用了一個建議選項api,在textfield變動的時候會呼叫api並以textfield當前的文字提供建議選項

3.api 3: 即時匯率api

   var response = await http
.get(Uri.parse('https://open.er-api.com/v6/latest/$inputCurrencyCode'));

根據輸入的param可以得到以該貨幣為基準進行的匯率回傳

這裡的功能下面不太會講到所以把使用的範例放在這

  • 將 JSON 轉換成自訂型別

api 1回傳的json很複雜而且很醜,以下長度僅擷取了一個來回航班的資料

{
"data": {
"context": {
"status": "incomplete",
"sessionId": "ClQIARJQCk4KJDMxMzQ3ZjQxLWYyZDQtNDMxYi1iOTQ0LTg4NjIzMmNhZjcxNhACGiRkODExYWU5Zi0zMWQ1LTQ5MzItODk5My04MmU1NGY5OWM3OTISKHVzc19iMTE4ZGRkMS0xZDFkLTQ1NDQtOGVjZS1iZmJiZDVlYzBlZTA=",
"totalResults": 10,
"filterTotalResults": 8
},
"itineraries": [
{
"id": "17075-2406080730--32331-0-12409-2406081100|12409-2406131945--32331-0-17075-2406132125",
"price": {
"raw": 16430.0,
"formatted": "NT$16,430",
"pricingOptionId": "F8tzMvXMDl9L"
},
"legs": [
{
"id": "17075-2406080730--32331-0-12409-2406081100",
"origin": {
"id": "TPE",
"entityId": "128667054",
"name": "台北桃園",
"displayCode": "TPE",
"city": "台北",
"country": "臺灣",
"isHighlighted": false
},
"destination": {
"id": "ICN",
"entityId": "95673659",
"name": "首爾仁川國際機場",
"displayCode": "ICN",
"city": "首爾",
"country": "韓國",
"isHighlighted": false
},
"durationInMinutes": 150,
"stopCount": 0,
"isSmallestStops": false,
"departure": "2024-06-08T07:30:00",
"arrival": "2024-06-08T11:00:00",
"timeDeltaInDays": 0,
"carriers": {
"marketing": [
{
"id": -32331,
"logoUrl": "https://logos.skyscnr.com/images/airlines/favicon/BR.png",
"name": "長榮航空"
}
],
"operationType": "fully_operated"
},
"segments": [
{
"id": "17075-12409-2406080730-2406081100--32331",
"origin": {
"flightPlaceId": "TPE",
"displayCode": "TPE",
"parent": {
"flightPlaceId": "TPET",
"displayCode": "TPE",
"name": "台北",
"type": "City"
},
"name": "台北桃園",
"type": "Airport",
"country": "臺灣"
},
"destination": {
"flightPlaceId": "ICN",
"displayCode": "ICN",
"parent": {
"flightPlaceId": "SELA",
"displayCode": "SEL",
"name": "首爾",
"type": "City"
},
"name": "首爾仁川國際機場",
"type": "Airport",
"country": "韓國"
},
"departure": "2024-06-08T07:30:00",
"arrival": "2024-06-08T11:00:00",
"durationInMinutes": 150,
"flightNumber": "170",
"marketingCarrier": {
"id": -32331,
"name": "長榮航空",
"alternateId": "BR",
"allianceId": 0,
"displayCode": ""
},
"operatingCarrier": {
"id": -32331,
"name": "長榮航空",
"alternateId": "BR",
"allianceId": 0,
"displayCode": ""
}
}
]
},
{
"id": "12409-2406131945--32331-0-17075-2406132125",
"origin": {
"id": "ICN",
"entityId": "95673659",
"name": "首爾仁川國際機場",
"displayCode": "ICN",
"city": "首爾",
"country": "韓國",
"isHighlighted": false
},
"destination": {
"id": "TPE",
"entityId": "128667054",
"name": "台北桃園",
"displayCode": "TPE",
"city": "台北",
"country": "臺灣",
"isHighlighted": false
},
"durationInMinutes": 160,
"stopCount": 0,
"isSmallestStops": false,
"departure": "2024-06-13T19:45:00",
"arrival": "2024-06-13T21:25:00",
"timeDeltaInDays": 0,
"carriers": {
"marketing": [
{
"id": -32331,
"logoUrl": "https://logos.skyscnr.com/images/airlines/favicon/BR.png",
"name": "長榮航空"
}
],
"operationType": "fully_operated"
},
"segments": [
{
"id": "12409-17075-2406131945-2406132125--32331",
"origin": {
"flightPlaceId": "ICN",
"displayCode": "ICN",
"parent": {
"flightPlaceId": "SELA",
"displayCode": "SEL",
"name": "首爾",
"type": "City"
},
"name": "首爾仁川國際機場",
"type": "Airport",
"country": "韓國"
},
"destination": {
"flightPlaceId": "TPE",
"displayCode": "TPE",
"parent": {
"flightPlaceId": "TPET",
"displayCode": "TPE",
"name": "台北",
"type": "City"
},
"name": "台北桃園",
"type": "Airport",
"country": "臺灣"
},
"departure": "2024-06-13T19:45:00",
"arrival": "2024-06-13T21:25:00",
"durationInMinutes": 160,
"flightNumber": "159",
"marketingCarrier": {
"id": -32331,
"name": "長榮航空",
"alternateId": "BR",
"allianceId": 0,
"displayCode": ""
},
"operatingCarrier": {
"id": -32331,
"name": "長榮航空",
"alternateId": "BR",
"allianceId": 0,
"displayCode": ""
}
}
]
}
],
"isSelfTransfer": false,
"isProtectedSelfTransfer": false,
"farePolicy": {
"isChangeAllowed": false,
"isPartiallyChangeable": false,
"isCancellationAllowed": false,
"isPartiallyRefundable": false
},
"fareAttributes": {},
"tags": [
"second_cheapest"
],
"isMashUp": false,
"hasFlexibleOptions": false,
"score": 0.999
},

"flightsSessionId": "31347f41-f2d4-431b-b944-886232caf716",
"destinationImageUrl": "https://content.skyscnr.com/m/3719e8f4a5daf43d/original/Flights-Placeholder.jpg",
"token": "eyJhIjoxLCJjIjowLCJpIjowLCJjYyI6ImVjb25vbXkiLCJvIjoiVFBFIiwiZCI6IlNFTEEiLCJkMSI6IjIwMjQtMDYtMDgiLCJkMiI6IjIwMjQtMDYtMTMifQ=="
},
"status": true,
"message": "Successful"
}

透過航班的自訂義型別只擷取需要的資訊顯示,但仍有許多地方需要自己轉換,例如根據網址規律自己拼出api裡沒有提供的航空公司圖片

  factory Flight.fromJson(Map<String, dynamic> json) {
if (json['legs'] == null || json['legs'].length < 2) {
throw ArgumentError('Invalid legs data');
}

var departureLegs = json['legs'][0]; // 去程的資料
var returnLegs = json['legs'][1]; // 回程的資料

String getFormattedDate(String dateTimeStr, String format) {
return DateFormat(format).format(DateTime.parse(dateTimeStr));
}
//根據網址規律自己拼出api裡沒有提供的航空公司圖片

String buildCarrierUrl(String alternateId) {
return 'https://www.skyscanner.net/images/airlines/small/$alternateId.png';
}

String formatMinutesToHoursAndMinutes(int totalMinutes) {
//把分鐘轉成時間表示
int hours = totalMinutes ~/ 60;
int minutes = totalMinutes % 60;

return '$hours小時$minutes分';
}

return Flight(
price: json['price']['formatted'] ?? 'N/A',
// 去程
origin1: departureLegs['origin']['displayCode'] ?? 'N/A',
destination1: departureLegs['destination']['displayCode'] ?? 'N/A',
departDate1: getFormattedDate(departureLegs['departure'], 'yyyy-MM-dd'),
departTime1: getFormattedDate(departureLegs['departure'], 'HH:mm'),
arriveDate1: getFormattedDate(departureLegs['arrival'], 'yyyy-MM-dd'),
arriveTime1: getFormattedDate(departureLegs['arrival'], 'HH:mm'),
duration1: formatMinutesToHoursAndMinutes(
departureLegs['durationInMinutes'] ?? 0),
carrier1: departureLegs['carriers']['marketing'][0]['name'] ?? 'N/A',
carrierUrl1: buildCarrierUrl(
departureLegs['segments'][0]['operatingCarrier']['alternateId']),
// 回程
origin2: returnLegs['origin']['displayCode'] ?? 'N/A',
destination2: returnLegs['destination']['displayCode'] ?? 'N/A',
departDate2: getFormattedDate(returnLegs['departure'], 'yyyy-MM-dd'),
departTime2: getFormattedDate(returnLegs['departure'], 'HH:mm'),
arriveDate2: getFormattedDate(returnLegs['arrival'], 'yyyy-MM-dd'),
arriveTime2: getFormattedDate(returnLegs['arrival'], 'HH:mm'),
duration2:
formatMinutesToHoursAndMinutes(returnLegs['durationInMinutes'] ?? 0),
carrier2: returnLegs['carriers']['marketing'][0]['name'] ?? 'N/A',
carrierUrl2: buildCarrierUrl(
returnLegs['segments'][0]['operatingCarrier']['alternateId']),
);
}
  • 畫面正在抓資料時顯示資料下載中,比方使用 LinearProgressIndicator。
  • 下拉更新功能,比方使用 RefreshIndicator。
  • 抓不到資料,比方網路有問題時,畫面上顯示錯誤資訊
  • 使用 SearchBar 實現 search 功能。

這裡選擇用search bar實現過濾航空公司的功能

  • 使用 FavQs API 開發註冊登入功能。
    final String apiUrl = 'https://favqs.com/api/session';
final String apiKey = '16e5b92e652c1616e6362c26ed186ebd'; //API 金鑰

Map<String, dynamic> credentials = {
"user": {
"login": account,
"password": password,
}
};

//登入POST request
final http.Response response = await http.post(
Uri.parse(apiUrl),
headers: <String, String>{
'Content-Type': 'application/json',
'Authorization': 'Token token=$apiKey', // 添加 API 金鑰到標頭
},
body: jsonEncode(credentials),
);
  • 使用 showDialog 或 showSnackBar 顯示登入失敗。
  • 使用到至少一個沒教過的功能技術,使用愈多分數愈高。可在文章裡特別說明使用哪些沒教的技術
  1. URL launcher套件

點擊航班跳到對應的購票網址,這部分也是根據網址規律自己拼湊出來的

import 'package:url_launcher/url_launcher.dart';
onPressed: () async {
String skyUrl =
'https://www.skyscanner.com.tw/transport/flights/$departure/$destination/$departDateSearchString/$returnDateSearchString/?adultsv2=$passengers&cabinclass=economy&childrenv2=&inboundaltsenabled=false&outboundaltsenabled=false&preferdirects=true&ref=home&rtn=1';
var url = Uri.parse(skyUrl);
if (await canLaunchUrl(url)) {
await launchUrl(url, mode: LaunchMode.externalApplication);
} else {
throw 'Could not launch $url';
}
},

原本想說只是一個套件而已應該很簡單結果卻試了超久一直無法成功,結果查了很多解法都沒用才看到有人說用這個套件的時候很常需要重新啟動專才能成功套用,於是這個方法才讓他成功可以用。

2. auto complete builder

原本有查到另一個auto complete textfield的套件,但後來發現內建的這個就很夠用了

    return Autocomplete<String>(
optionsBuilder: (TextEditingValue textEditingValue) async {
if (textEditingValue.text == '') {
return const Iterable<String>.empty();
} else {
await _fetchSuggestions(textEditingValue.text);
}
return _suggestions;
},
fieldViewBuilder: (BuildContext context, TextEditingController controller,
FocusNode fieldFocusNode, VoidCallback onFieldSubmitted) {
return TextField(
controller: controller,
focusNode: fieldFocusNode,
decoration: InputDecoration(
contentPadding: const EdgeInsets.fromLTRB(16.0, 12.0, 12.0, 12.0),
prefixIcon: const Icon(Icons.location_on),
filled: true,
fillColor: const Color.fromARGB(255, 192, 171, 214),
hintText: widget.isdepart ? '出發地' : '目的地',
border: const OutlineInputBorder(),
),
style: const TextStyle(fontSize: 18),
keyboardType: TextInputType.text,
);
},
onSelected: (String value) {
//點擊後取得sky id才能得到對api請求的參數
if (widget.isdepart) {
departure = skyIDMap[value] ?? '';
print('departure ID: $departure');
} else {
destination = skyIDMap[value] ?? '';
print('destination ID: $destination');
}
debugPrint('You just selected $value');
},
);

        _suggestions = data
.map((item) => item['presentation']['suggestionTitle'] as String)
.toList();
skyIDMap = {
for (var item in data)
item['presentation']['suggestionTitle']: item['presentation']
['skyId']
};

產生建議列表並且創建對應的id的map對照表,才能得到對航班api請求的參數

加分功能:

  • 包含其它欄位的註冊畫面,比方性別,國家。
  • 動畫

1.左右移動動畫

class AnimationMove extends StatefulWidget {
@override
_AnimationMoveState createState() => _AnimationMoveState();
}

class _AnimationMoveState extends State<AnimationMove>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<Offset> _animation;

@override
void initState() {
super.initState();

_controller = AnimationController(
duration: const Duration(seconds: 2),
vsync: this,
)..repeat(reverse: true);

_animation = Tween<Offset>(
begin: Offset(-1, 0),
end: Offset(1, 0),
).animate(CurvedAnimation(
parent: _controller,
curve: Curves.easeInOut,
));
}

@override
void dispose() {
_controller.dispose();
super.dispose();
}

@override
Widget build(BuildContext context) {
return SlideTransition(
position: _animation,
child: Image.asset(
'assets/airplane.PNG',
width: 200,
height: 200,
),
);
}
}

2.左右搖擺動畫

class AnimationSwing extends StatefulWidget {
@override
_AnimationSwingState createState() => _AnimationSwingState();
}

class _AnimationSwingState extends State<AnimationSwing>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _animation;

@override
void initState() {
super.initState();

_controller = AnimationController(
duration: const Duration(seconds: 1),
vsync: this,
)..repeat(reverse: true);

_animation = Tween<double>(
begin: -0.5, // 左右搖擺的角度(弧度),-0.1 和 0.1 分別表示 -5.7° 和 5.7°
end: 0.1,
).animate(CurvedAnimation(
parent: _controller,
curve: Curves.easeInOut,
));
}

@override
void dispose() {
_controller.dispose();
super.dispose();
}

@override
Widget build(BuildContext context) {
return Center(
child: AnimatedBuilder(
animation: _animation,
builder: (context, child) {
return Transform.rotate(
angle: _animation.value,
child: child,
);
},
child: Image.asset(
'assets/airplane2.PNG',
width: 300,
height: 300,
),
),
);
}
}
  • 自動登入。

登入時儲存token

       import 'package:shared_preferences/shared_preferences.dart';
print('登入成功!');
_showLoginDialog('登入成功!', "");
//儲存token
token = responseBody['User-Token'];
username = account;
// print('token2: $token');
SharedPreferences prefs = await SharedPreferences.getInstance();
await prefs.setString('token', token)

進到登入頁面時檢查token是否存在,token存在會直接進到主頁

//取得之前儲存的token
void _getToken() async {
// SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
String loginToken = prefs.getString('token') ?? '';
print('token: $loginToken');
if (loginToken.isNotEmpty) {
Navigator.of(context).push(
MaterialPageRoute(builder: (context) => ContentPage()),
);
}
}

心得:很久沒有做這種一個人的期末專案感覺有點神奇,之前團體的專案剛好api的部分都不是我負責所以這次算第一次正式接觸,接觸之後發現沒有想像中難,而且基本上是按照我個人的需求來寫所以蠻好玩的。

--

--