# 用 Flutter 與 KKBOX API 做出科科歌單 ## 前言 大家好,我是 KKBOX 開放平台部門的小小實習生 Raymond,今天想分享一下自己學習 Flutter 並使用 KKBOX Open API 做出一個簡單的 App 的心路歷程,這篇文章首先會簡單的介紹一下 Flutter,我取得 Flutter 資源的方式,再來就是實作 KKBOX 報報的部分以及學習感想,希望能讓大家有所收穫。 ## Flutter 是什麼? Flutter 是由 Google 所開發出的跨平台開發套件,可同時運行於 iOS 與 Android,跨平台,高效能(跟 React Native 比起來),以及開發效率極高的特性讓許多人一試成主顧。在 [StackOverflow Developer Survey 2019](https://insights.stackoverflow.com/survey/2019) 的調查結果中,Flutter 是 **Most Loved Other Frameworks, Libraries, and Tools** 中的第三名,甚至高於 React Native,而 Flutter team 在今年的 Google I/O 大會上釋出了 Flutter for web 的 technical preview,未來也將會支援 Desktop 與 Embedding system,可以說是極具發展潛力的開發套件。 ### Flutter 的哪些特點吸引了我? 為何 Flutter 如此受歡迎,大家都一用就愛上它?他與同是跨平台開發的 React Native 有什麼不一樣?經過了一番搜尋與研究,我整理出了一些大家喜愛的點(在等下的實作中你也會體驗到) 1. 快速的開發:寫過 Android 就知道 Gradle build 會花上很多時間,而在開發時我們常常需要重複的 build 而拖慢開發速度,Flutter 的 hot reload 只要在一開始啟動時進行一次 Gradle build,之後的任何對程式碼的更改只要按下 Ctrl + S,Flutter 就會自動進行 UI 的重構,讓你可以快速的檢視所做的改變,跟 Android 原生開發比其來真的是天壤之別。 1. Layout 與邏輯使用同一種語言:不像 Android 使用分別的 XML 檔來設計 Layout,Flutter 使用同一種語言(Dart)來設計 Layout 與邏輯。 1. 真.跨平台:相比於同是跨平台的 React Native 使用的是原生平台元件,Flutter 不使用原生平台元件而使用自己的元件(叫做 Widget),因此 Flutter 可以真正做到在任何平台都長得一樣得 UI,且 Flutter 所提供的 Widget 都非常的漂亮、有很高的擴充性與可以高度客製化。 1. 效能:因為使用自己的 Widget,Flutter 不需要與原生平台上的元件溝通,只需要一張可以畫畫的 Canvas,因此比起 React Native 在效能上會有較好的表現,尤其是動畫或是使用者做出手勢時,Flutter 可以達到 60fps 的表現。 關於其他 Flutter 的特色,我推薦這一篇 [What’s Revolutionary about Flutter](https://medium.com/hackernoon/whats-revolutionary-about-flutter-946915b09514),裡面很深入的探討了 Flutter 為何如此讓人興奮,以及 Flutter 使用 Dart 的好處。 如果以上這些引起了你的興趣,接下來我將會分享我如何使用 Flutter 串接 KKBOX Open API 豐富的歌曲資源來做出一個簡單卻漂亮的科科歌單 App,將 KKBOX 的各式歌單與排行榜展示出來。 ![](https://miro.medium.com/max/960/1*mGoczC81ws90thooKP0vZA.gif) 如果想要動手玩玩看,我把它上架到 [Google Play Store](https://play.google.com/store/apps/details?id=com.raymondchang.personal.kkbox.playground) 了。 ### 學習的資源 以下是我學習 Flutter 時走過的資源,大家可以當作參考 [Flutter官方文件](https://flutter.dev/docs)— 不得不說 Flutter 的官方 tutorial 與 doc 真的寫得詳細易懂。 [Flutter for Android developer](https://flutter.dev/docs/get-started/flutter-for/android-devs)— 如果你學過 Android,這篇文章可以讓你快速上手。 [A tour of Dart language](https://dart.dev/guides/language/language-tour)— 有學過物件導向語言的話 Dart 應該是蠻好上手的。 [Flutter Youtube 頻道](https://www.youtube.com/watch?v=wgTBLj7rMPM&list=PLjxrf2q8roU2HdJQDjJzOeO6J3FoFLWr2)— 官方開辦的頻道,我有兩個推薦的清單,一個是 Flutter widget of the week,一個是 Flutter in Focus。 ## 不過 先讓我們來聊聊 KKBOX Open API > KKBOX Open API 是快速整合 KKBOX 平台的高品質音樂資料的主要管道,您可利用各種程式語言透過 HTTP API 取得歌手、專輯、歌曲、排行榜等資訊,方便實作音樂應用於網頁、Mobile App 之中。 — *KKBOX developer site* 簡單來說,KKBOX 提供了許多歌曲資訊讓大家取用,而運用這些豐富的歌曲資源,我們就可以做出許多有趣的應用,例如 [KUBE 酷播歌單](https://www.kube-app.com/today),使用 KKBOX 的歌單資源並可以選擇使用 KKBOX、Spotify 或是 Youtube 播放,讓你永遠不會有缺歌期。 除此之外,也有像是[ Line Bot](https://docs-en.kkbox.codes/docs/developer-showcase) 放音樂或是[智慧音箱](https://www.kkbox.com/service/smartspeaker/)使用語音控制的應用,如果你對如何運用 KKBOX 開放平台的應用有了興趣,或是想知道更多它有趣的應用,可以到 KKBOX developer site 的 [API doc](https://docs-zhtw.kkbox.codes/docs) 逛逛,或許你會發現一些意想不到的靈感! ## 製作科科歌單 ### 事前準備 * 如果你還沒在 KKBOX developer site 註冊並新增 App,快去註冊一個吧。 ![](https://miro.medium.com/max/1400/1*kZsM59KWE_na9WUdfV92rQ.png) 到 KKBOX for developers 註冊後點擊 my apps -> create new app 並填上你的 app 資料 ![](https://miro.medium.com/max/1400/1*WGWgkOfaA5nSmv7EOSka1Q.png) 創建完成後,將你的 ID 與 Secret 記錄下來,之後在向 Open API 要資料時會使用到 * 確認你安裝好了 Android Studio, Flutter SDK 與 Flutter plugin for android studio(你也可以使用 VS code),如果你還沒安裝,這份[官方文件](https://flutter.dev/docs/get-started/install)有著詳盡的安裝步驟,安裝完成後可以在 terminal 輸入 `flutter doctor` 確認安裝完成。 ![](https://cdn-images-1.medium.com/max/1400/1*V9QXWvtyrJskUzCfG1LswQ.png) 確認第 1,2,5 項都有勾勾 * 這篇文章假設你至少完成了 Flutter 官方的 [Get started tutorial](https://flutter.dev/docs/get-started/install),並且知道[什麼是 Web API](https://www.youtube.com/watch?v=zvKadd9Cflc&t=12s)。 ### 先從 Dependency 開始 打開 `<project dir>/pubspec.yaml`,在 dependencies 下方加入 http 與 url_launcher 兩個套件。[http](https://pub.dev/packages/http) 是用於向 Web API 抓取資料的套件,[url_launcher](https://pub.dev/packages/url_launcher) 則是用於開啟歌單連結的套件。 ![](https://cdn-images-1.medium.com/max/1600/1*Nlz3unTb1igHPOGH2_H15w.png) 確認 http 與 url_launcher 已經加入了 dependencies 內 ### 從 KKBOX Open API 抓取資料 #### 使用 Dart http 套件 Dart team 提供了非常方便的 http 套件,讓我們能輕鬆地從網路抓取資料,下面是一個簡單的 Get 與 Post 的程式碼。 ``` import 'package:http/http.dart' as http; Future<http.Response> fetchGet()async { String url = 'http://....'; Map<String,String> headers{ 'Header-name':'header-content' }; return await http.get(url,headers); } Future<http.Response> fetchPost()async { String url = 'http://....'; Map<String,String> headers{ 'Header-name':'header-content' }; Map<String,String> body{ 'body-name':'body-content' }; return await http.post(url,headers,body); } ``` `fetchGet` 與 `fetchPost` 傳回一個 `Future<Response>` 物件,Future 物件是 Dart 內建的物件,用於表示預期未來才會出現的值,像是 Response 就是要等到 server 方做出了回覆才會拿到獲得的物件。 `async` 與 `await` 是兩個 Dart 的關鍵字,這兩個方便的關鍵字讓我們用看起來同步的方式寫非同步的method,避免了可怕的 callback hell 。 #### KKBOX client credential flow 想要從 KKBOX Open API 獲取資料,需要先透過 client credential flow 取得 access token,可以將 access token 想成 server 給予你的臨時識別證。我們使用之前記錄下來的 ID 與 Secret 來向 Token API 索取 access token,如果 ID 與 Secret 都正確,API 會回覆以下三個欄位,記得將取得的 access token 儲存起來。 { "access_token": "fCVTwABPlcO6Qxc7Ll23rsdsf", //用來獲取其他API的資料 "expires_in": 1492982700, //access_token過期時間 "token_type": "Bearer" } ``` getAccessToken() async { Static const String credentialFLowUrl = 'https://account.kkbox.com/oauth2/token'; Map<String,String> requestBody = { 'grant_type':'client_credentials', 'client_id':YOUR_CLIENT_ID, 'client_secret':YOUR_CLIENT_SECRET }; Map<String,String> header = { 'Content-type':'application/x-www-form-urlencoded' }; final response = await http.post(credentialFlowUrl, headers:header, body:requestBody); return json.decode(response.body)['access_token']; } ``` #### 使用 Access Token 向 Open API 抓取特色歌單 有了 token 後,就可以開始使用 KKBOX Open API 的資料,方法是在每個 HTTP request 的 header 中放入 `Authorization: Bearer my_access_token` 這個欄位,我們以 featured playlists 這個 endpoint 為例: ``` getFeaturedPlaylists()async{ String endpoint = 'https://api.kkbox.com/v1.1/featured-playlists?territory=TW' Map<String,String> headers = {'Authorization':'Bearer '+ await getAccessToken()}; var response = await http.get(endPoint,headers); print(response.body); } ``` 以下是 response body: ``` { "data": [ { "id": "Wt95My35CqR9hB_FW1", "title": "一起複習少女時代的十年輝煌時刻!", "description": "2007年8月,少女時代以同名專輯於韓國出道,可人的少女形象深入人心,十年來靠著努力與實力獲得人氣,也在樂壇創造不少紀錄,膾炙人口的歌曲更是不計其數。", "url": "https://event.kkbox.com/content/playlist/Wt95My35CqR9hB_FW1", "images": [ { "height": 300, "width": 300, "url": "https://i.kfs.io/playlist/global/11264817v2/cropresize/300x300.jpg" }, { "height": 600, "width": 600, "url": "https://i.kfs.io/playlist/global/11264817v2/cropresize/600x600.jpg" }, { "height": 1000, "width": 1000, "url": "https://i.kfs.io/playlist/global/11264817v2/cropresize/1000x1000.jpg" } ], "updated_at": "2017-08-01T03:59:31+00:00", "owner": { "id": "D-gh_8TEudW64tRSCs", "name": "KKBOX 韓語小編" } }, { "//":"省略~" } ], "paging": { "offset": 0, "limit": 5, "previous": null, "next": "https://api.kkbox.com/v1.1/featured-playlists?limit=5&territory=TW&offset=5" }, "summary": { "total": 30 } } ``` #### 將 Json Response 轉換為專屬 Dart 物件 我們當然可以使用好用的 `json.decode(response)` 來解析 json 字串,但是當你有個肥大的 json 檔案,將 response 轉化為 dart 內的自訂物件會讓你的人生更加輕鬆。除了自己手動建立 model class,也可以用這個非常好用的轉換工具 [quicktype](https://quicktype.io/) 幫你生成 Class code。自動生成後再做一些命名的微調後就成為我們的 model.dart 拉! ``` import 'dart:convert'; Playlists playlistsFromJson(String str) => Playlists.fromJson(json.decode(str)); String playlistsToJson(Playlists data) => json.encode(data.toJson()); class Playlists { List<Datum> data; Paging paging; Summary summary; Playlists({ this.data, this.paging, this.summary, }); factory Playlists.fromJson(Map<String, dynamic> json) => new Playlists( data: new List<Datum>.from(json["data"].map((x) => Datum.fromJson(x))), paging: Paging.fromJson(json["paging"]), summary: Summary.fromJson(json["summary"]), ); Map<String, dynamic> toJson() => { "data": new List<dynamic>.from(data.map((x) => x.toJson())), "paging": paging.toJson(), "summary": summary.toJson(), }; } class Datum { String id; String title; String description; String url; List<ImageUrl> imageUrls; DateTime updatedAt; Owner owner; Datum({ this.id, this.title, this.description, this.url, this.imageUrls, this.updatedAt, this.owner, }); factory Datum.fromJson(Map<String, dynamic> json) => new Datum( id: json["id"], title: json["title"], description: json["description"], url: json["url"], imageUrls: new List<ImageUrl>.from(json["images"].map((x) => ImageUrl.fromJson(x))), updatedAt: DateTime.parse(json["updated_at"]), owner: Owner.fromJson(json["owner"]), ); Map<String, dynamic> toJson() => { "id": id, "title": title, "description": description, "url": url, "images": new List<dynamic>.from(imageUrls.map((x) => x.toJson())), "updated_at": updatedAt.toIso8601String(), "owner": owner.toJson(), }; } class ImageUrl { int height; int width; String url; ImageUrl({ this.height, this.width, this.url, }); factory ImageUrl.fromJson(Map<String, dynamic> json) => new ImageUrl( height: json["height"], width: json["width"], url: json["url"], ); Map<String, dynamic> toJson() => { "height": height, "width": width, "url": url, }; } class Owner { String id; String name; Owner({ this.id, this.name, }); factory Owner.fromJson(Map<String, dynamic> json) => new Owner( id: json["id"], name: json["name"], ); Map<String, dynamic> toJson() => { "id": id, "name": name, }; } class Paging { int offset; int limit; dynamic previous; String next; Paging({ this.offset, this.limit, this.previous, this.next, }); factory Paging.fromJson(Map<String, dynamic> json) => new Paging( offset: json["offset"], limit: json["limit"], previous: json["previous"], next: json["next"], ); Map<String, dynamic> toJson() => { "offset": offset, "limit": limit, "previous": previous, "next": next, }; } class Summary { int total; Summary({ this.total, }); factory Summary.fromJson(Map<String, dynamic> json) => new Summary( total: json["total"], ); Map<String, dynamic> toJson() => { "total": total, }; } ``` 現在我們可以輕鬆地使用下面這行程式碼將 JSON response 轉換成 Dart 物件了 playlistsFromJson(response.body) 而我們將與 API 連線的功能包在一個 Class 放進 service.dart 裡: ``` import 'package:flutter_material/model.dart'; import 'package:http/http.dart' as http; import 'dart:convert'; class KKBOXOpenAPI{ static const String oauthTokenUrl='https://account.kkbox.com/oauth2/token'; static const String openApiBase= 'https://api.kkbox.com/v1.1/'; String _clientID; String _clientSecret; String _accessToken; KKBOXOpenAPI(String clientID, String clientSecret){ this._clientID = clientID; this._clientSecret = clientSecret; } Future<Playlists>getFeaturedPlaylist() async{ String endPoint = openApiBase + 'featured-playlists?territory=TW'; Map<String,String> headers = { 'Authorization':'Bearer '+ await _getAccessToken() }; var response = await this._get(endPoint,headers); return playlistsFromJson(response.body); } Future<Playlists> getNewHitsPlaylist() async { String endPoint = openApiBase + 'new-hits-playlists?territory=TW'; Map<String,String> headers = { 'Authorization':'Bearer '+ await _getAccessToken() }; var response = await this._get(endPoint, headers); return playlistsFromJson(response.body); } Future<Playlists> getChart() async { String endPoint = openApiBase + 'charts?territory=TW'; Map<String,String> headers = { 'Authorization':'Bearer '+ await _getAccessToken() }; var response = await this._get(endPoint, headers); return playlistsFromJson(response.body); } _get(url,headers) async{ final response = await http.get(url,headers: headers); return response; } _post(url,headers,bodyParameters)async{ final response = await http.post(url,headers: headers,body: bodyParameters); return response; } _getAccessToken()async { if(this._accessToken != null){ return this._accessToken; } Map<String,String> requestBody = { 'grant_type':'client_credentials', 'client_id':_clientID, 'client_secret':_clientSecret }; Map<String,String> header = { 'Content-type':'application/x-www-form-urlencoded' }; final response = await this._post(oauthTokenUrl, header, requestBody); this._accessToken = json.decode(response.body)['access_token']; return this._accessToken; } } ``` 這樣與 API 連線獲取資料的部分就大功告成拉! ### 探索 Flutter 的內建 Widget Flutter 最吸引我的地方就是可以用非常快的速度打造出非常漂亮的 UI,來看看這個 App 所用到的 widget 們。 #### 創建空白 App 我們先在 main.dart 加入以下程式碼來創建一個空白的 App。 ``` import 'package:flutter/material.dart'; import 'package:flutter_material/model.dart'; import 'package:flutter_material/service.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:flutter/rendering.dart'; void main(){ runApp(MyApp()); } class MyApp extends StatelessWidget { const MyApp(); @override Widget build(BuildContext context) { return MaterialApp( theme: ThemeData( primarySwatch: Colors.lightBlue, buttonColor: Colors.lightBlue, buttonTheme: ButtonThemeData( textTheme: ButtonTextTheme.primary, ) ), home: Home(), ); } } class Home extends StatefulWidget { @override State<StatefulWidget> createState() { return _HomeState(); } } class _HomeState extends State<Home>{ @override Widget build(BuildContext context) { return Scaffold(); } } ``` Home 是一個 StatefulWidget ,這代表 Home 的內容會有不同的狀態(不同的 UI),Home 的 createState() 會回傳 _HomeState 這個 State object,裡面有建構 UI 的 build method。我們先讓 build method 回傳一個空的 Scaffold Widget,Scaffod 是一個實作了基本的 material design 的 layout widget(如 bottom navigation bar,app bar,drawer 等等)。 #### Bottom Navigation 我們用 bottom navigation bar 分出 Open API 的三個 endpoints:[Charts](https://docs-zhtw.kkbox.codes/v1.1/reference#c)、[Featured Playlists](https://docs-zhtw.kkbox.codes/v1.1/reference#fp) 與 [NewHits Playlists](https://docs-zhtw.kkbox.codes/v1.1/reference#nhp)。 ``` //inside _HomeState _bodyBuilder(){ switch(_currentIndex){ case 0: return Center(child: Text("速報新歌"),); case 1: return Center(child: Text("主題歌單"),); case 2: return Center(child: Text("排行榜"),); } } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text( "科科歌單", style: TextStyle(color: Colors.white), ), ), body: _bodyBuilder(), bottomNavigationBar: BottomNavigationBar( currentIndex: _currentIndex, items: [ BottomNavigationBarItem( icon: new Icon(Icons.whatshot), title: new Text("速爆新歌"), ), BottomNavigationBarItem( icon: new Icon(Icons.apps), title: new Text("主題歌單",), ), BottomNavigationBarItem( icon: new Icon(Icons.list), title: new Text("排行榜") ) ], onTap: (int index){ setState(() { this._currentIndex =index;}); }, ), ); } ``` 在 Scaffold 中加入 appBar、body、bottomNavigationBar 三個引數,在 BottomNavigationBar 中的 items 參數放入 BottonNavigationBarItem 的 list,list 裡包含了希望出現在 bottom bar 的選項,先將 currentindex 預設為 1,currentindex 告訴 bottom bar 目前所選中的選項是哪一個。我們在 onTap 內實作 bottom bar 切換時進行 `setState` 讓 widget tree 重新做 build,而 build 時 `_bodyBuilder` 依據 `_currentIndex` 回傳不同的 body,完成後的成果如下。 ![](https://cdn-images-1.medium.com/max/1400/1*7vBbWYISCH3X9YIrGRVTFA.gif) #### ListView 我們使用 ListView 來將從 API 拿到的資料顯示在 App 上,而 Flutter 內建的 ListView.Builder 讓我們可以輕鬆的製作出獨一無二的 ListView widget。先來看看 Scaffold 內 body 的設置。 ``` //inside Scaffold body: FutureBuilder<Playlists>( future: _bodyBuilder(), builder: (context,snapshot){ if(snapshot.connectionState == ConnectionState.done) { this.playlist = snapshot.data; return ListView.builder( itemCount: this.playlist.data.length, itemBuilder: _listItemBuilder ); } else{ return Center( child: CircularProgressIndicator(), ); } } ), ``` body 內放入 FutureBuilder 這個 Widget 會與 future 參數互動並根據 snapshot 的狀態進行 build,在這裡 FutureBuilder 在需要較長時間的 _bodyBuilder() 還未完成前會回傳 CircularProgressIndicator 表示資料還沒準備好,而 _bodyBuilder 完成後所回傳的物件(此例為 Playlists 物件)會放在 snapshot 裡面。 ``` KKBOXOpenAPI kkboxOpenAPI = new KKBOXOpenAPI('YOUR_CLIENT_ID', 'YOUR_CLIENT_SECRET'); Future<Playlists> _bodyBuilder() async{ switch(_currentIndex){ case 0: return await kkboxOpenAPI.getNewHitsPlaylist(); case 1: return await kkboxOpenAPI.getFeaturedPlaylist(); case 2: return await kkboxOpenAPI.getChart(); } } ``` 完成連線後我們將資料存入 class 內的變數 playlists 內,然後寫一個 `_listItemBuilder` 製作客製化的 list item。 ``` //inside _HomeState Widget _listItemBuilder(BuildContext context,int index){ return Padding( padding: const EdgeInsets.only(bottom: 32), child: GestureDetector( onTap: ()=> showDialog(context: context, builder: (context)=>_dialogBuilder(context,index)), child: Stack( alignment: Alignment.bottomLeft, children: <Widget>[ Image.network( this.playlists.data[index].imageUrls[1].url, fit:BoxFit.fitWidth, ), Padding( padding: const EdgeInsets.only(left: 8,bottom: 8), child: Text( playlists.data[index].title, style: new TextStyle( color:Colors.white , fontSize: 30, fontWeight: FontWeight.bold, shadows: <Shadow>[ Shadow( blurRadius: 3.0, color: Color.fromARGB(255, 0, 0, 0), ), ], ), overflow: TextOverflow.ellipsis, ), ), ], ), ), ); } ``` 我們的 list item 主要由使用 stack widget 在一張圖片上疊加文字組成,再用 `GestureDetector` 包起來,而當使用者點擊了 item(onTap),會呼叫 showDialog method 跳出一個詳細資訊的對話方塊。ListItem.Builder 會依據參數 itemCount 來呼叫 `_listItemBuilder(context,index)`,而 `_listemItemBuilder` 再依據 index 向 playlists 拿取歌單的資料來建造 widget。 ![](https://cdn-images-1.medium.com/max/1400/1*djITxpHUpooxCV9O_2fQDw.png) #### Dialog 現在讓我們來完成最後一哩路,製作 `_dialogBuilder` 。 ``` //inside _HomeState Widget _dialogBuilder(BuildContext context,int index){ return SimpleDialog( contentPadding: EdgeInsets.zero, children: [ Image.network( this.playlists.data[index].imageUrls[1].url, fit:BoxFit.fill ), Padding( padding: EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: <Widget>[ Text( this.playlists.data[index].title, style: (Theme.of(context)).textTheme.title, ), SizedBox(height: 10,), Text(this.playlists.data[index].description), SizedBox(height: 16,), RaisedButton( onPressed: (){ _launchUrl(this.playlists.data[index].url); }, child: const Text("Listen"), ) ], ), ) ], ); } _launchUrl(String url) async{ if(await canLaunch(url)){ await launch(url); }else{ throw "could not launch $url"; } } ``` Flutter 除了內建了漂亮的 material design widget,我們也可以很輕鬆地製作客製化 Widget,`_dailogBuilder` 回傳一個 SimpleDialog,我們在 SimpleDialog 內放入圖片、歌單標題、歌單描述,與一個 Listen 的按鈕,按下之後會打開 KKBOX 的 App 並播放歌單。 ![](https://cdn-images-1.medium.com/max/1400/1*r-CdkggK_CRhDpODMYO1XQ.png) 以下是最後 main.dart 的內容 ``` import 'package:flutter/material.dart'; import 'package:flutter_material/model.dart'; import 'package:flutter_material/service.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:flutter/rendering.dart'; void main(){ debugPaintSizeEnabled = false; runApp(MyApp()); } class MyApp extends StatelessWidget { const MyApp(); @override Widget build(BuildContext context) { return MaterialApp( theme: ThemeData( primarySwatch: Colors.lightBlue, buttonColor: Colors.lightBlue, buttonTheme: ButtonThemeData( textTheme: ButtonTextTheme.primary, ) ), home: Home(), ); } } class Home extends StatefulWidget { @override State<StatefulWidget> createState() { // TODO: implement createState return _HomeState(); } } class _HomeState extends State<Home>{ int _currentIndex=1; Playlists playlists; KKBOXOpenAPI kkboxOpenAPI = new KKBOXOpenAPI( '5bffb622103056b75181bc1f68cc2bde', 'eb378c8431b314b21b16b1beb4e4e8e8'); Widget _dialogBuilder(BuildContext context,int index){ return SimpleDialog( contentPadding: EdgeInsets.zero, children: [ Image.network( this.playlists.data[index].imageUrls[1].url, fit:BoxFit.fill ), Padding( padding: EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: <Widget>[ Text( this.playlists.data[index].title, style: (Theme.of(context)).textTheme.title, ), SizedBox(height: 10,), Text(this.playlists.data[index].description), SizedBox(height: 16,), RaisedButton( onPressed: (){ _launchUrl(this.playlists.data[index].url); }, child: const Text("Listen"), ) ], ), ) ], ); } _launchUrl(String url) async{ if(await canLaunch(url)){ await launch(url); }else{ throw "could not launch $url"; } } Widget _listItemBuilder(BuildContext context,int index){ return Padding( padding: const EdgeInsets.only(bottom: 32), child: GestureDetector( onTap: ()=> showDialog(context: context, builder: (context)=>_dialogBuilder(context,index)), child: Stack( alignment: Alignment.bottomLeft, children: <Widget>[ Image.network( this.playlists.data[index].imageUrls[1].url, fit:BoxFit.fitWidth, ), Padding( padding: const EdgeInsets.only(left: 8,bottom: 8), child: Text( playlists.data[index].title, style: new TextStyle( color:Colors.white , fontSize: 30, fontWeight: FontWeight.bold, shadows: <Shadow>[ Shadow( blurRadius: 3.0, color: Color.fromARGB(255, 0, 0, 0), ), ], ), overflow: TextOverflow.ellipsis, ), ), ], ), ), ); } Future<Playlists> _bodyBuilder() async{ switch(_currentIndex){ case 0: return await kkboxOpenAPI.getNewHitsPlaylist(); break; case 1: return await kkboxOpenAPI.getFeaturedPlaylist(); break; case 2: return await kkboxOpenAPI.getChart(); break; default: return null; } } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text( "科科歌單", style: TextStyle(color: Colors.white), ), ), body: FutureBuilder<Playlists>( future: _bodyBuilder(), builder: (BuildContext context,AsyncSnapshot<Playlists> snapshot){ if(snapshot.connectionState == ConnectionState.done) { this.playlists = snapshot.data; return ListView.builder( itemCount: this.playlists.data.length, itemBuilder: _listItemBuilder ); } else{ return Center( child: CircularProgressIndicator(), ); } } ), bottomNavigationBar: BottomNavigationBar( currentIndex: _currentIndex, items: [ BottomNavigationBarItem( icon: new Icon(Icons.whatshot), title: new Text("速爆新歌"), ), BottomNavigationBarItem( icon: new Icon(Icons.apps), title: new Text("主題歌單",), ), BottomNavigationBarItem( icon: new Icon(Icons.list), title: new Text("排行榜") ) ], onTap: (int index){ setState(() { this._currentIndex =index; }); }, ), ); } } ``` 大功告成,完成了一個簡單小巧的 Flutter App,如果你需要完整的程式碼,我把它放在了 [Github](https://github.com/we10710aa/flutter_kkbox_api/tree/tutorial) 上了。 ## 結語 幾天下來學習 Flutter 就會感覺出為何它如此受大家愛戴了,豐富而且好看的 Widget、Ctrl+S 就會將改變顯示出來的 hot reload 還有好上手卻強大的 Dart 語言(尤其是 async/await),寫起來會讓人很開心。而除了特色歌單之外,KKBOX 的開放平台也提供了歌曲搜尋,歌曲電台等其他資源,如果有什麼 Idea,只要有一個開發者帳號就可以用任何程式語言開始打造你的 App 了,希望大家看在實習生的份上多多參觀(鞠躬),謝謝大家。