[FLUTTER]-觀光資訊搜尋 & Google Places API

黃于恩
海大 SwiftUI iOS / Flutter App 程式設計
19 min readJun 10, 2024

API來源: 交通部觀光資訊資料庫 & Google Places

// Import libraries for asynchronous programming, json decoding, and building the user interface
import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;

// Main function to run the app
void main() {
runApp(MyApp());
}

// Class to manage data fetching
class DataManager {
// Create a http client for making network requests
final client = http.Client();

// Define the URL of the scenic spot data API
final String url =
"https://media.taiwan.net.tw/XMLReleaseALL_public/scenic_spot_C_f.json";

// Function to fetch scenic spot data asynchronously
Future<dynamic> fetchData() async {
// Make a GET request to the API endpoint
final response = await client.get(
Uri.parse(url),
// Set the Content-Type header to application/json
headers: {'Content-Type': 'application/json'},
);

// Check if the response status code is 200 (success)
if (response.statusCode == 200) {
// Decode the response body using UTF8 decoder
final utf8decoder = Utf8Decoder();
final body = utf8decoder.convert(response.bodyBytes);

// Parse the decoded JSON body
return json.decode(body);
} else {
// Throw an exception if the request fails
throw Exception('Failed to load data');
}
}
}

// Main app class
class MyApp extends StatelessWidget {
// Create an instance of DataManager
final DataManager dataManager = DataManager();

@override
Widget build(BuildContext context) {
return MaterialApp(
// Set the app title
title: '交通部觀光資訊資料庫景點搜尋串接Google Places圖片',
// Define the app theme
theme: ThemeData(
primarySwatch: Colors.blue,
),
// Set the home screen of the app
home: ScenicSpotsScreen(dataManager: dataManager),
);
}
}

// Widget to display a photo fetched from Google Places API
class PhotoWidget extends StatefulWidget {
final String placeName; // Define placeName parameter

PhotoWidget({required this.placeName});

@override
_PhotoWidgetState createState() => _PhotoWidgetState();
}

class _PhotoWidgetState extends State<PhotoWidget> {
final String apiKey = 'API Key'; //輸入API
String? photoUrl;

@override
void initState() {
super.initState();
fetchPhoto(widget.placeName); // Fetch photo on initialization
}

Future<void> fetchPhoto(String placeName) async {
final String url =
'https://maps.googleapis.com/maps/api/place/findplacefromtext/json?input=$placeName&inputtype=textquery&fields=photos&key=$apiKey';

final response = await http.get(Uri.parse(url));
if (response.statusCode == 200) {
final data = json.decode(response.body);
final photos = data['candidates'][0]['photos'];
if (photos != null && photos.isNotEmpty) {
String photoReference = photos[0]['photo_reference'];
String photoUrl = 'https://maps.googleapis.com/maps/api/place/photo?maxwidth=400&photoreference=$photoReference&key=$apiKey';
setState(() {
this.photoUrl = photoUrl;
});
}
} else {
throw Exception('Failed to load photo');
}
}

@override
Widget build(BuildContext context) {
return photoUrl != null
? Image.network(photoUrl!)
: SizedBox.shrink(); // Return empty SizedBox if no photo found
}
}

// Class representing the scenic spots screen
class ScenicSpotsScreen extends StatefulWidget {
// DataManager instance passed to the screen
final DataManager dataManager;

ScenicSpotsScreen({required this.dataManager});

@override
_ScenicSpotsScreenState createState() => _ScenicSpotsScreenState();
}

class _ScenicSpotsScreenState extends State<ScenicSpotsScreen> {
// Define variables for future data, data list, search controller, and filtered list
late Future<dynamic> _futureData;
List<dynamic> _dataList = [];
List<dynamic> _filteredDataList = [];
final TextEditingController _searchController = TextEditingController();

// Initialize state when the widget is built
@override
void initState() {
super.initState();
// Fetch data initially
_futureData = widget.dataManager.fetchData();
_searchController.addListener(_onSearchChanged);
}

// Dispose search controller
@override
void dispose() {
_searchController.removeListener(_onSearchChanged);
_searchController.dispose();
super.dispose();
}

// Function to handle search input changes
void _onSearchChanged() {
setState(() {
String query = _searchController.text.toLowerCase();
_filteredDataList = _dataList
.where((item) =>
item['Name'].toString().toLowerCase().contains(query) ||
item['Toldescribe'].toString().toLowerCase().contains(query))
.toList();
});
}

// Function to refresh the data by refetching it and updating the UI
Future<void> _refreshData() async {
// Update the state to indicate that data is being fetched
setState(() {
_futureData = widget.dataManager.fetchData();
});
}

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('觀光資訊搜尋 & Google Places API'),
),
body: RefreshIndicator(
onRefresh: _refreshData,
child: Column(
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: TextField(
controller: _searchController,
decoration: InputDecoration(
labelText: 'Search',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12.0),
),
suffixIcon: IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
},
),
),
),
),
Expanded(
child: FutureBuilder(
future: _futureData,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return Center(child: CircularProgressIndicator());
} else if (snapshot.hasError) {
return Center(child: Text('Error: ${snapshot.error}'));
} else {
final dynamic data = snapshot.data;

if (data != null) {
_dataList = data['XML_Head']['Infos']['Info'];
_filteredDataList = _filteredDataList.isEmpty
? _dataList
: _filteredDataList;
return ListView.builder(
itemCount: _filteredDataList.length,
itemBuilder: (context, index) {
final Map<String, dynamic> item =
_filteredDataList[index];
return Card(
margin: EdgeInsets.all(8.0),
child: ListTile(
title: Text((item['Region'] ?? '') +
'-' +
(item['Name'] ?? '')),
subtitle: Text(item['Toldescribe'] ?? ''),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
ScenicSpotDetailScreen(item: item),
),
);
},
),
);
},
);
} else {
return Center(child: Text('No data available'));
}
}
},
),
),
],
),
),
);
}
}

// Widget representing the detailed screen for a scenic spot
class ScenicSpotDetailScreen extends StatelessWidget {
// Map containing the scenic spot information
final Map<String, dynamic> item;

ScenicSpotDetailScreen({required this.item});

@override
Widget build(BuildContext context) {
return Scaffold(
// Set the app bar title to the scenic spot name
appBar: AppBar(
title: Text((item['Name']) ?? ''),
),
body: SingleChildScrollView(
child: Padding(
// Add padding to the body content
padding: const EdgeInsets.all(16.0),
child: Column(
// Align the column content to the start
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Display the scenic spot name and region with bold styling
Text(
(item['Region'] ?? '') + '-' + (item['Name'] ?? ''),
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
),
SizedBox(height: 10),
// Display the scenic spot description
Text(item['Toldescribe'] ?? '', style: TextStyle(fontSize: 16)),
SizedBox(height: 20),
// Add Px Py
Text(
'Address: ' + (item['Add'] ?? 'N/A') +
' (' + (item['Px'].toString() ?? 'N/A') + ', ' + (item['Py'].toString() ?? 'N/A') + ')',
style: TextStyle(fontSize: 16),
),
SizedBox(height: 10),
// Opentime
item['Opentime'] != null && item['Opentime'].isNotEmpty
? Text(
'Open Time: ' + item['Opentime'],
style: TextStyle(fontSize: 16),
)
: SizedBox.shrink(),
// Travellinginfo
item['Travellinginfo'] != null && item['Travellinginfo'].isNotEmpty
? Text(
'Travellinginfo: ' + item['Travellinginfo'],
style: TextStyle(fontSize: 16),
)
: SizedBox.shrink(),
// Ticketinfo
item['Ticketinfo'] != null && item['Ticketinfo'].isNotEmpty
? Text(
'Ticketinfo: ' + item['Ticketinfo'],
style: TextStyle(fontSize: 16),
)
: SizedBox.shrink(),
// Display the phone number with label
Text(
'Tel: ' + (item['Tel'] ?? 'N/A'),
style: TextStyle(fontSize: 16),
),
// Changetime
item['Changetime'] != null && item['Changetime'].isNotEmpty
? Text(
'Changetime: ' + item['Changetime'],
style: TextStyle(fontSize: 16),
)
: SizedBox.shrink(),
// Display photo using PhotoWidget
PhotoWidget(placeName: item['Name'] ?? 'N/A'),
PhotoWidget(placeName: item['Add'] ?? 'N/A'),
],
),
),
),
);
}
}

Github: https://github.com/remote5izy/flutter/tree/master/my_app4

Flutter 專案心得與紀錄

專案背景與目標

在這個 Flutter 專案中,我致力於開發一個景點搜尋和展示的應用程式。目標是提供一個友好且功能多元的界面,讓使用者能夠方便地瀏覽和了解台灣各地的景點資訊。專案除了要涵蓋基本的景點資訊外,還需要支持搜尋功能,並展示每個景點的相關圖片。這項專案結合了實時數據抓取、UI設計以及API整合等多個技術點,是一次非常全面的學習和實踐過程。

技術選型與設計考量

使用 Flutter 作為開發框架,讓我一次性開發出適用於 iOS 和 Android 的應用程式;Flutter 豐富的 Widget 和強大的 UI 設計能力,讓我能夠快速迭代界面設計。

在設計方面,考量到使用者的體驗,我決定實現一個直觀的搜尋功能,允許使用者根據名稱或描述進行篩選。此外,展示每個景點的圖片也成為設計中的一個重點。圖片展示功能計劃通過整合 Google Places API 來實現,這樣可以為使用者提供更豐富的視覺資料。

開發過程中的挑戰

1. API 整合:
在整合景點資料 API 和 Google Places API 時,我遇到了一些挑戰。首先是如何解析和處理來自 API 的 JSON 數據。為此,我花了一些時間學習 Dart 中的 JSON 解析方法,並成功使用 `json.decode` 來處理 API 返回的數據。同時,在整合 Google Places API 來獲取圖片時,還需要處理 API 密鑰的管理和如何解析多層嵌套的 JSON 結構。

2. 搜尋功能的實現:
搜尋功能需要能夠在大量資料中快速定位到使用者所需的景點。為此,我實現了輸入框的防抖(debounce)功能,避免過於頻繁地觸發搜尋過程,提升了效能。這涉及到使用 `Timer` 來控制搜尋請求的觸發時間,並且需要確保在搜尋時不阻塞主執行序。

3. UI 設計與優化:
在設計 UI 時,我希望能夠達到簡潔且美觀的效果,這就需要深入了解 Flutter 的各種 Widget。特別是在設計景點列表和詳細頁面時,如何合理地使用 `ListView`、`Card` 和 `ListTile` 等 Widget 是一個重點。我還需要確保應用程式在不同設備上的顯示效果一致且優化良好。

成就與學習

這個專案使我學到了很多關於 Flutter 的知識,尤其是在以下幾個方面:

1. 狀態管理:
透過這個專案,我對於 Flutter 的狀態管理有了更深入的理解,尤其是如何利用 `StatefulWidget` 和 `setState` 來動態更新 UI。

2. API 資料處理:
掌握了如何使用 Dart 語言解析 JSON 數據,這對於處理從網絡獲取的資料非常有幫助。我學會了如何正確地解碼和解析複雜的 JSON 資料結構。

3. UI 設計與實現:
我熟悉了 Flutter 的布局系統,能夠有效地使用各種 Widget 來構建複雜的界面。同時,對於如何實現響應式設計和確保良好的用戶體驗有了更多實踐經驗。

4. Google API 整合:
學習並實踐了如何與第三方 API(如 Google Places API)進行整合,並從中瞭解了 API 認證、請求構建及數據處理等細節問題。

結語

整體來說,這個 Flutter 專案讓我對現代移動應用開發有了全面的了解。從最初的設計到最終的實現,每個階段都有不同的挑戰,但也充滿了學習的樂趣。通過不斷地調整和優化,我不僅提升了自己的技術能力,也更加理解了如何從用戶的角度去思考問題和設計功能。這次經驗將對我未來的開發工作提供寶貴的指導和參考。

--

--